Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bce582dde0 |
@@ -1,18 +0,0 @@
|
|||||||
node_modules
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.claude
|
|
||||||
.svelte-kit
|
|
||||||
build
|
|
||||||
dist
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.vscode
|
|
||||||
.idea
|
|
||||||
target
|
|
||||||
*.apk
|
|
||||||
*.aab
|
|
||||||
*.log
|
|
||||||
coverage
|
|
||||||
src-tauri/gen
|
|
||||||
src-tauri/target
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Local Android release signing.
|
|
||||||
#
|
|
||||||
# Copy to `.env` and fill in. `.env` is gitignored and is the single source of
|
|
||||||
# truth for local release signing — scripts/write-keystore-properties.sh reads
|
|
||||||
# it and regenerates src-tauri/gen/android/keystore.properties before every
|
|
||||||
# release build, because `tauri android init` overwrites that file.
|
|
||||||
#
|
|
||||||
# Only needed for `bun run android:build:release`. Debug builds sign with the
|
|
||||||
# local debug keystore and need nothing here.
|
|
||||||
#
|
|
||||||
# CI does not use this file: build-release.yml reconstructs the keystore from
|
|
||||||
# the ANDROID_KEYSTORE_BASE64 secret and writes the same properties itself.
|
|
||||||
|
|
||||||
# Key alias inside the keystore.
|
|
||||||
ANDROID_KEY_ALIAS=jellytau
|
|
||||||
|
|
||||||
# Absolute path to the .jks. Keep it outside the repo, or in the gitignored
|
|
||||||
# android-keystore/ directory.
|
|
||||||
ANDROID_KEYSTORE_FILE=/absolute/path/to/jellytau-release.jks
|
|
||||||
|
|
||||||
# Keystore and key passwords. These are secrets — never commit the filled-in .env.
|
|
||||||
ANDROID_KEYSTORE_PASSWORD=
|
|
||||||
ANDROID_KEY_PASSWORD=
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
name: Bug report
|
|
||||||
about: Something behaves incorrectly
|
|
||||||
title: ""
|
|
||||||
labels: ["bug"]
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Security vulnerabilities do **not** go here — see
|
|
||||||
[SECURITY.md](../../SECURITY.md).
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: what-happened
|
|
||||||
attributes:
|
|
||||||
label: What happened
|
|
||||||
description: What you did, what you expected, and what you got instead.
|
|
||||||
placeholder: |
|
|
||||||
1. Opened an album from the Music library
|
|
||||||
2. Tapped the third track
|
|
||||||
3. Playback started from the first track instead
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: version
|
|
||||||
attributes:
|
|
||||||
label: JellyTau version
|
|
||||||
description: Settings scrolls to the bottom, or the filename you installed.
|
|
||||||
placeholder: "0.9.1"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
id: platform
|
|
||||||
attributes:
|
|
||||||
label: Platform
|
|
||||||
options:
|
|
||||||
- Linux (AppImage)
|
|
||||||
- Linux (deb)
|
|
||||||
- Linux (rpm)
|
|
||||||
- Linux (Arch package)
|
|
||||||
- Windows
|
|
||||||
- Android
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
### Playback questions
|
|
||||||
|
|
||||||
If this involves playback, these three answers decide which of several
|
|
||||||
very different code paths you were on. "I don't know" is a fine answer.
|
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
id: source
|
|
||||||
attributes:
|
|
||||||
label: Was the media streaming or downloaded?
|
|
||||||
options:
|
|
||||||
- Streaming from the server
|
|
||||||
- Downloaded for offline use
|
|
||||||
- Not playback-related
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
id: transcode
|
|
||||||
attributes:
|
|
||||||
label: Was the server transcoding?
|
|
||||||
description: Jellyfin's dashboard shows this while something is playing.
|
|
||||||
options:
|
|
||||||
- Direct play
|
|
||||||
- Transcoding
|
|
||||||
- Don't know
|
|
||||||
- Not playback-related
|
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
id: kind
|
|
||||||
attributes:
|
|
||||||
label: Music or video?
|
|
||||||
options:
|
|
||||||
- Music
|
|
||||||
- Video (movie)
|
|
||||||
- Video (TV episode)
|
|
||||||
- Not playback-related
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: logs
|
|
||||||
attributes:
|
|
||||||
label: Logs
|
|
||||||
description: |
|
|
||||||
Android: `adb logcat | grep -i jellytau`.
|
|
||||||
Linux: run from a terminal, or `RUST_LOG=debug jellytau` for more.
|
|
||||||
In the app, `localStorage.setItem("jellytau:logLevel","debug")` in the
|
|
||||||
webview console turns the frontend up too.
|
|
||||||
render: shell
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: server
|
|
||||||
attributes:
|
|
||||||
label: Jellyfin server
|
|
||||||
description: Version, and anything unusual about the library layout.
|
|
||||||
placeholder: "10.9.11, series stored without season folders"
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
name: Feature request
|
|
||||||
about: Suggest something JellyTau should do
|
|
||||||
title: ""
|
|
||||||
labels: ["enhancement"]
|
|
||||||
body:
|
|
||||||
- type: textarea
|
|
||||||
id: problem
|
|
||||||
attributes:
|
|
||||||
label: What are you trying to do?
|
|
||||||
description: |
|
|
||||||
The situation, not the solution. "I listen to albums in a fixed order and
|
|
||||||
lose my place when I switch devices" tells us more than "add a sync
|
|
||||||
button", and often has a better answer than the one you had in mind.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: proposal
|
|
||||||
attributes:
|
|
||||||
label: What would you like it to do?
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
id: platform
|
|
||||||
attributes:
|
|
||||||
label: Which platforms does this matter on?
|
|
||||||
multiple: true
|
|
||||||
options:
|
|
||||||
- Linux
|
|
||||||
- Windows
|
|
||||||
- Android
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: alternatives
|
|
||||||
attributes:
|
|
||||||
label: Anything you have tried, or how other clients handle it
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
## What and why
|
|
||||||
|
|
||||||
<!-- What changes, and the reason. The diff shows the what; the why is what
|
|
||||||
the commit log is for. -->
|
|
||||||
|
|
||||||
## How it was verified
|
|
||||||
|
|
||||||
<!-- What you actually ran or clicked. "Tests pass" on its own says little;
|
|
||||||
"played a transcoded episode on Android, seeked twice, backgrounded it"
|
|
||||||
says a lot. -->
|
|
||||||
|
|
||||||
## Checklist
|
|
||||||
|
|
||||||
- [ ] `bun run check`, `bun run test`, `bun run format:check`, `bun run lint`
|
|
||||||
- [ ] `cargo fmt`, `cargo clippy --all-targets -- -D warnings`, `cargo test`
|
|
||||||
- [ ] `bun run check:boundary` — no Jellyfin taxonomy in the frontend
|
|
||||||
- [ ] New requirement-implementing code carries a `TRACES:` comment, and every
|
|
||||||
ID it names exists in `docs/requirements.md` (`bun run traces:validate`)
|
|
||||||
- [ ] **Bug fix:** a test that reproduces it was written *first* and observed
|
|
||||||
failing before the fix
|
|
||||||
- [ ] Android source edits were made in `src-tauri/android/src` and synced with
|
|
||||||
`scripts/sync-android-sources.sh` (never edit `gen/` directly)
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
name: '🏗️ Build and Test JellyTau'
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
paths-ignore:
|
|
||||||
- '**/*.md'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
paths-ignore:
|
|
||||||
- '**/*.md'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
# Incremental state is never reused between CI runs -- pure disk cost.
|
|
||||||
CARGO_INCREMENTAL: 0
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
name: Run Tests
|
|
||||||
# A release push triggers build-release.yml on the tag, which runs this exact
|
|
||||||
# test suite itself — and on a single-slot runner the two ~1h workflows would
|
|
||||||
# otherwise serialize/contend. Skip the duplicate for chore(release) commits.
|
|
||||||
# (head_commit is absent on pull_request/workflow_dispatch; startsWith(null,…)
|
|
||||||
# is false there, so those events still run.)
|
|
||||||
if: "!startsWith(github.event.head_commit.message, 'chore(release)')"
|
|
||||||
runs-on: linux/amd64
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Cache Rust dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
# Registry only -- never src-tauri/target. That directory is ~16 GB and
|
|
||||||
# was cached under five separate keys, which filled the runner's 74 GB
|
|
||||||
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
|
|
||||||
# registry/src is omitted too: cargo re-extracts it for free from
|
|
||||||
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry/index
|
|
||||||
~/.cargo/registry/cache
|
|
||||||
~/.cargo/git/db
|
|
||||||
# One shared key across every job. The old per-job keys existed to stop
|
|
||||||
# debug/release target artifacts clobbering each other; with target no
|
|
||||||
# longer cached, registry contents are target-independent, so all jobs
|
|
||||||
# want the same crates. First job to finish saves; the rest restore.
|
|
||||||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-registry-
|
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.bun/install/cache
|
|
||||||
node_modules
|
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
bun install
|
|
||||||
|
|
||||||
# Tripwire for domain-taxonomy leaks into the presentation layer (a
|
|
||||||
# multi-type includeItemTypes query defining a category in the frontend).
|
|
||||||
# See scripts/check-frontend-boundary.sh and
|
|
||||||
# docs/specs/scoped-search-boundary.md.
|
|
||||||
- name: Check frontend/backend boundary
|
|
||||||
run: bash scripts/check-frontend-boundary.sh
|
|
||||||
|
|
||||||
# The docs are the maintained source of truth for architecture and
|
|
||||||
# process, and they cross-reference each other heavily. A rename that
|
|
||||||
# misses a link turns a doc into a dead end silently. Pure shell + git —
|
|
||||||
# no tool is installed at job time.
|
|
||||||
- name: Check documentation links
|
|
||||||
run: bash scripts/check-doc-links.sh
|
|
||||||
|
|
||||||
# Formatting, linting and type-checking were all configured in this repo
|
|
||||||
# and enforced by nothing: .prettierrc described a tree where 199 files did
|
|
||||||
# not match it, eslint.config.js ran in no workflow and in no hook, and
|
|
||||||
# `bun run check` ran only in build-release.yml — i.e. a type error could
|
|
||||||
# sit on master until somebody cut a tag. These three steps are what make
|
|
||||||
# those configs load-bearing. All are project deps installed by
|
|
||||||
# `bun install`; nothing is fetched at job time.
|
|
||||||
# Cheap tripwire for a class of defect this repo kept hitting: tooling on
|
|
||||||
# a rarely-taken path. scripts/build-android.sh ran `npm install` on its
|
|
||||||
# clean-build branch -- in a bun project, ignoring bun.lock and
|
|
||||||
# re-resolving the tree, which is how the Tauri plugin crate/package
|
|
||||||
# versions drifted apart and broke a release build. It survived because
|
|
||||||
# clean builds are rare.
|
|
||||||
- name: Check build tooling
|
|
||||||
run: bash scripts/check-tooling.sh
|
|
||||||
|
|
||||||
- name: Check formatting
|
|
||||||
run: bun run format:check
|
|
||||||
|
|
||||||
# RATCHET — this number only ever goes DOWN. Same policy as MIN_THRESHOLD
|
|
||||||
# in traceability-check.yml and the coverage thresholds in
|
|
||||||
# vitest.config.ts. 159 is what the tree carried when the gate went in; the
|
|
||||||
# backlog is real findings (dead bindings, unkeyed {#each}, `any` at the
|
|
||||||
# IPC boundary) that eslint.config.js documents rule by rule, each parked
|
|
||||||
# at "warn" until its class is cleared and it can be promoted to "error".
|
|
||||||
# Lower this as you clear them. Never raise it to make a build pass.
|
|
||||||
- name: Lint
|
|
||||||
run: bun run lint -- --max-warnings=158
|
|
||||||
|
|
||||||
- name: Check TypeScript
|
|
||||||
run: |
|
|
||||||
bunx svelte-kit sync
|
|
||||||
bun run check
|
|
||||||
|
|
||||||
# Tauri refuses to build when a plugin's Rust crate and npm package are on
|
|
||||||
# different minor versions. Nothing here runs `tauri build` -- that only
|
|
||||||
# happens on a tag -- so a mismatch introduced on master stayed invisible
|
|
||||||
# until the release build, which is where it was found: v0.10.0 prep hit
|
|
||||||
# `tauri-plugin-log (v2.8.0) : @tauri-apps/plugin-log (v2.9.0)`. `cargo
|
|
||||||
# check`, clippy, the tests and svelte-check had all passed.
|
|
||||||
#
|
|
||||||
# `tauri info` performs the same comparison the bundler does, without a
|
|
||||||
# build. Grepping its output is crude, but the alternative is discovering
|
|
||||||
# this at tag time again.
|
|
||||||
- name: Check Tauri plugin versions match
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
if bunx tauri info 2>&1 | tee /tmp/tauri-info.txt | grep -q "version mismatched"; then
|
|
||||||
echo "::error::A Tauri plugin's Rust crate and npm package versions disagree."
|
|
||||||
echo "::error::The release build will refuse to start. Align them in"
|
|
||||||
echo "::error::src-tauri/Cargo.toml and package.json (both are pinned exactly)."
|
|
||||||
grep -A6 "version mismatched" /tmp/tauri-info.txt || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "✅ Tauri plugin crate/package versions agree."
|
|
||||||
|
|
||||||
# Coverage rather than a bare `bun run test`: same suite, plus the
|
|
||||||
# thresholds in vitest.config.ts, so a large untested module or a deleted
|
|
||||||
# test fails here instead of being noticed months later.
|
|
||||||
- name: Run frontend tests
|
|
||||||
run: |
|
|
||||||
bunx svelte-kit sync
|
|
||||||
bun run test:coverage
|
|
||||||
|
|
||||||
# CLAUDE.md has required `cargo fmt` + `cargo clippy` before every commit
|
|
||||||
# for as long as the rule has existed, but nothing in CI checked either,
|
|
||||||
# so the requirement rested entirely on memory. Both components are baked
|
|
||||||
# into the builder image (Dockerfile.builder: `rustup component add
|
|
||||||
# rustfmt clippy`) — nothing is installed at job time.
|
|
||||||
- name: Check Rust formatting
|
|
||||||
run: |
|
|
||||||
cd src-tauri
|
|
||||||
cargo fmt --all -- --check
|
|
||||||
|
|
||||||
# Clippy is a hard gate. It was advisory while the tree carried a warning
|
|
||||||
# backlog; that backlog is gone (0 warnings on 1.97.1, the pinned
|
|
||||||
# toolchain), so a warning here is now new breakage rather than old noise.
|
|
||||||
#
|
|
||||||
# This only means anything because src-tauri/rust-toolchain.toml pins the
|
|
||||||
# compiler: clippy's lint set moves between releases, so an unpinned gate
|
|
||||||
# would fail on whatever the runner happened to install. The pin and this
|
|
||||||
# flag stand or fall together — if you unpin, drop this back to advisory.
|
|
||||||
- name: Run clippy
|
|
||||||
run: |
|
|
||||||
cd src-tauri
|
|
||||||
cargo clippy --all-targets -- -D warnings
|
|
||||||
|
|
||||||
- name: Run Rust tests
|
|
||||||
run: |
|
|
||||||
cd src-tauri
|
|
||||||
cargo test
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
# Fast per-commit Android compile check. This does NOT build a shippable APK:
|
|
||||||
# the full signed release APK is built only on tag pushes by build-release.yml
|
|
||||||
# (which runs sync-android-sources.sh + signing). Running the full bundle here
|
|
||||||
# too would duplicate a ~15min build and, without the sync step, produced an
|
|
||||||
# unsigned APK missing our custom sources/icons/proguard rules anyway.
|
|
||||||
# `cargo check` for the Android target (~1min) catches Android-specific Rust
|
|
||||||
# breakage without linking, bundling, or signing.
|
|
||||||
android-check:
|
|
||||||
name: Android Compile Check
|
|
||||||
runs-on: linux/amd64
|
|
||||||
needs: test
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
|
||||||
env:
|
|
||||||
ANDROID_HOME: /opt/android-sdk
|
|
||||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
|
||||||
NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
|
||||||
ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Cache Rust dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
# Registry only -- never src-tauri/target. That directory is ~16 GB and
|
|
||||||
# was cached under five separate keys, which filled the runner's 74 GB
|
|
||||||
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
|
|
||||||
# registry/src is omitted too: cargo re-extracts it for free from
|
|
||||||
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry/index
|
|
||||||
~/.cargo/registry/cache
|
|
||||||
~/.cargo/git/db
|
|
||||||
# One shared key across every job. The old per-job keys existed to stop
|
|
||||||
# debug/release target artifacts clobbering each other; with target no
|
|
||||||
# longer cached, registry contents are target-independent, so all jobs
|
|
||||||
# want the same crates. First job to finish saves; the rest restore.
|
|
||||||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-registry-
|
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.bun/install/cache
|
|
||||||
node_modules
|
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
|
|
||||||
- name: Cargo check (aarch64-linux-android)
|
|
||||||
run: |
|
|
||||||
TC="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
|
||||||
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$TC/aarch64-linux-android24-clang"
|
|
||||||
export CC_aarch64_linux_android="$TC/aarch64-linux-android24-clang"
|
|
||||||
export AR_aarch64_linux_android="$TC/llvm-ar"
|
|
||||||
cd src-tauri
|
|
||||||
cargo check --target aarch64-linux-android --lib
|
|
||||||
|
|
||||||
# Supply-chain gate. Until this job existed the project had no vulnerability
|
|
||||||
# scanning of any kind: nothing checked the ~500-crate Rust graph or the JS
|
|
||||||
# dependencies against a CVE feed, and nothing checked that everything we
|
|
||||||
# redistribute is licence-compatible with shipping JellyTau under MIT.
|
|
||||||
#
|
|
||||||
# The first run of this found eight vulnerabilities and one unsoundness
|
|
||||||
# (bytes, four in rustls-webpki, time, two in quick-xml, rand) — all fixed by
|
|
||||||
# `cargo update`, none of which anybody had reason to run.
|
|
||||||
#
|
|
||||||
# Runs in parallel with android-check rather than after `test`: a dependency
|
|
||||||
# advisory has nothing to do with whether the tests pass, and finding out
|
|
||||||
# sooner is the point.
|
|
||||||
security:
|
|
||||||
name: Supply Chain
|
|
||||||
runs-on: linux/amd64
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Cache Rust dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry/index
|
|
||||||
~/.cargo/registry/cache
|
|
||||||
~/.cargo/git/db
|
|
||||||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-registry-
|
|
||||||
|
|
||||||
# cargo-deny is baked into the builder image. It fetches the RustSec
|
|
||||||
# advisory database at run time — that is *data*, like the crates
|
|
||||||
# `bun install` fetches, not a toolchain install, so the 🔴 rule in
|
|
||||||
# CLAUDE.md is not in play here.
|
|
||||||
#
|
|
||||||
# Config and every documented exception live in src-tauri/deny.toml.
|
|
||||||
# Vulnerabilities and unsoundness are hard failures with no override;
|
|
||||||
# unmaintained transitive crates that have no safe upgrade (Tauri's GTK3
|
|
||||||
# stack, the unic-* tables) are ignored there by ID, each with a reason.
|
|
||||||
- name: cargo-deny (advisories, licences, bans, sources)
|
|
||||||
run: |
|
|
||||||
cd src-tauri
|
|
||||||
cargo deny check
|
|
||||||
|
|
||||||
# Advisory for now, deliberately. The Rust graph was clean after one
|
|
||||||
# update pass, so gating it costs nothing; the JS graph has not been
|
|
||||||
# audited before and a first run that fails the build teaches everyone to
|
|
||||||
# ignore this job. Promote to a hard gate once the output is empty and
|
|
||||||
# stays empty — same approach that got clippy from advisory to -D warnings.
|
|
||||||
- name: bun audit (advisory)
|
|
||||||
run: |
|
|
||||||
bun install
|
|
||||||
bun audit || echo "::warning::bun audit reported findings — advisory for now, see CLAUDE.md"
|
|
||||||
@@ -1,715 +0,0 @@
|
|||||||
name: Build & Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: 'Version to build (e.g., v1.0.0)'
|
|
||||||
required: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
RUST_BACKTRACE: 1
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
# Incremental state is never reused between CI runs -- pure disk cost.
|
|
||||||
CARGO_INCREMENTAL: 0
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
name: Run Tests
|
|
||||||
runs-on: linux/amd64
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Cache Rust dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
# Registry only -- never src-tauri/target. That directory is ~16 GB and
|
|
||||||
# was cached under five separate keys, which filled the runner's 74 GB
|
|
||||||
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
|
|
||||||
# registry/src is omitted too: cargo re-extracts it for free from
|
|
||||||
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry/index
|
|
||||||
~/.cargo/registry/cache
|
|
||||||
~/.cargo/git/db
|
|
||||||
# One shared key across every job. The old per-job keys existed to stop
|
|
||||||
# debug/release target artifacts clobbering each other; with target no
|
|
||||||
# longer cached, registry contents are target-independent, so all jobs
|
|
||||||
# want the same crates. First job to finish saves; the rest restore.
|
|
||||||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-registry-
|
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.bun/install/cache
|
|
||||||
node_modules
|
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
|
|
||||||
- name: Run frontend tests
|
|
||||||
run: |
|
|
||||||
bunx svelte-kit sync
|
|
||||||
bun run test --run
|
|
||||||
continue-on-error: false
|
|
||||||
|
|
||||||
# Same gate as build-and-test.yml. A release must not ship from a tree
|
|
||||||
# that would fail the per-commit checks. rustfmt/clippy come from the
|
|
||||||
# builder image; nothing is installed here.
|
|
||||||
- name: Check Rust formatting
|
|
||||||
run: |
|
|
||||||
cd src-tauri
|
|
||||||
cargo fmt --all -- --check
|
|
||||||
continue-on-error: false
|
|
||||||
|
|
||||||
# Advisory until the ~51 pre-existing warnings are cleared; see the longer
|
|
||||||
# note in build-and-test.yml. Tighten both to `-- -D warnings` together.
|
|
||||||
- name: Run clippy (advisory)
|
|
||||||
run: |
|
|
||||||
cd src-tauri
|
|
||||||
cargo clippy --all-targets
|
|
||||||
|
|
||||||
- name: Run Rust tests
|
|
||||||
run: bun run test:rust
|
|
||||||
continue-on-error: false
|
|
||||||
|
|
||||||
- name: Check TypeScript
|
|
||||||
run: bun run check
|
|
||||||
continue-on-error: false
|
|
||||||
|
|
||||||
build-linux:
|
|
||||||
name: Build Linux
|
|
||||||
runs-on: linux/amd64
|
|
||||||
needs: test
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Cache Rust dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
# Registry only -- never src-tauri/target. That directory is ~16 GB and
|
|
||||||
# was cached under five separate keys, which filled the runner's 74 GB
|
|
||||||
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
|
|
||||||
# registry/src is omitted too: cargo re-extracts it for free from
|
|
||||||
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry/index
|
|
||||||
~/.cargo/registry/cache
|
|
||||||
~/.cargo/git/db
|
|
||||||
# One shared key across every job. The old per-job keys existed to stop
|
|
||||||
# debug/release target artifacts clobbering each other; with target no
|
|
||||||
# longer cached, registry contents are target-independent, so all jobs
|
|
||||||
# want the same crates. First job to finish saves; the rest restore.
|
|
||||||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-registry-
|
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.bun/install/cache
|
|
||||||
node_modules
|
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
|
|
||||||
# The Linux job previously had no version step at all, so a tagged release
|
|
||||||
# built Linux packages from whatever version happened to be committed.
|
|
||||||
- name: Set app version from tag
|
|
||||||
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
|
|
||||||
# TAURI_SKIP_UPDATER is gone: it was suppressing the updater artifacts
|
|
||||||
# (.AppImage.tar.gz + .sig) that the update manifest points at, back when
|
|
||||||
# there was no updater to feed. With the signing key present, `tauri build`
|
|
||||||
# emits and signs them.
|
|
||||||
#
|
|
||||||
# If TAURI_SIGNING_PRIVATE_KEY is ever absent the build fails loudly rather
|
|
||||||
# than quietly shipping an unsigned release that no client will accept --
|
|
||||||
# which is the behaviour we want.
|
|
||||||
# Same hazard as the Windows job: the bundle directory is never cleaned by
|
|
||||||
# cargo and the runner reuses src-tauri/target, while the copy step below
|
|
||||||
# globs bundle/deb/*.deb and friends. Windows is where this actually bit
|
|
||||||
# (v0.8.2 shipped thirteen stale installers), but only because Linux
|
|
||||||
# packaging is newer -- the glob is identical. Remove the directory so a
|
|
||||||
# stale artifact cannot exist to be copied.
|
|
||||||
- name: Clear previous bundle output
|
|
||||||
run: rm -rf src-tauri/target/release/bundle
|
|
||||||
|
|
||||||
- name: Build for Linux
|
|
||||||
run: bun run tauri build
|
|
||||||
env:
|
|
||||||
# linuxdeploy's bundled `strip` cannot parse the `.relr.dyn` section
|
|
||||||
# modern toolchains emit, and fails on every bundled library:
|
|
||||||
# strip: libzstd.so.1: unknown type [0x13] section `.relr.dyn'
|
|
||||||
# failed to bundle project `failed to run linuxdeploy`
|
|
||||||
# Ubuntu 23.10+ links with -z pack-relative-relocs by default, so this
|
|
||||||
# image hits it. Skipping strip is linuxdeploy's documented escape
|
|
||||||
# hatch; the cost is a larger AppImage. Found by building the target
|
|
||||||
# locally before tagging -- nothing in CI builds the app, so a release
|
|
||||||
# would have been the first time anyone discovered the AppImage target
|
|
||||||
# does not work.
|
|
||||||
NO_STRIP: "true"
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Prepare Linux artifacts
|
|
||||||
run: |
|
|
||||||
mkdir -p dist/linux
|
|
||||||
# Match by extension, not by product name. Bundle filenames follow
|
|
||||||
# `productName`, so renaming the app (jellytau -> JellyTau) made the
|
|
||||||
# old `jellytau_*.deb` glob match nothing — and because the copy was
|
|
||||||
# wrapped in `if [ -f ... ]`, the artifact simply vanished from the
|
|
||||||
# release with no error. Each bundle directory holds one file.
|
|
||||||
#
|
|
||||||
# `if [ -f "dir/"*.ext ]` was also wrong on its own terms: with more
|
|
||||||
# than one match `test` gets extra arguments and fails.
|
|
||||||
#
|
|
||||||
# No `shopt -s nullglob` here: the runner executes `run:` blocks with
|
|
||||||
# POSIX sh, where shopt does not exist -- it exited 127 and killed the
|
|
||||||
# step (which is why v0.9.0 and v0.9.1 built but never published).
|
|
||||||
# Without nullglob an unmatched pattern stays literal, so test each
|
|
||||||
# candidate instead. Same POSIX-only rule as traceability-check.yml.
|
|
||||||
#
|
|
||||||
# Tauri v2 signs the .AppImage ITSELF and writes <name>.AppImage.sig
|
|
||||||
# beside it -- there is no .AppImage.tar.gz unless
|
|
||||||
# bundle.createUpdaterArtifacts is set to "v1Compatible". The updater
|
|
||||||
# downloads the same AppImage a human does and verifies that .sig, so
|
|
||||||
# both files must ship or the manifest points at a signature nobody
|
|
||||||
# can fetch.
|
|
||||||
for bundle in \
|
|
||||||
src-tauri/target/release/bundle/appimage/*.AppImage \
|
|
||||||
src-tauri/target/release/bundle/appimage/*.AppImage.sig \
|
|
||||||
src-tauri/target/release/bundle/deb/*.deb \
|
|
||||||
src-tauri/target/release/bundle/rpm/*.rpm; do
|
|
||||||
[ -e "$bundle" ] || continue
|
|
||||||
cp -v "$bundle" dist/linux/
|
|
||||||
done
|
|
||||||
|
|
||||||
# An AppImage that did not build means no updater artifact either, and
|
|
||||||
# the release notes have advertised an AppImage for months. Fail rather
|
|
||||||
# than publish a release whose manifest points at nothing.
|
|
||||||
if ! ls dist/linux/*.AppImage >/dev/null 2>&1; then
|
|
||||||
echo "::error::No AppImage produced -- check bundle.targets in tauri.conf.json"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# A release with no Linux package is a failure, not a quiet success.
|
|
||||||
if [ -z "$(ls -A dist/linux/)" ]; then
|
|
||||||
echo "::error::No Linux bundles found under src-tauri/target/release/bundle/"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
ls -lah dist/linux/
|
|
||||||
|
|
||||||
- name: Upload Linux build artifact
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: jellytau-linux
|
|
||||||
path: dist/linux/
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
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:2026.08.1
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Cache Rust dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
# Registry only -- never src-tauri/target. That directory is ~16 GB and
|
|
||||||
# was cached under five separate keys, which filled the runner's 74 GB
|
|
||||||
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
|
|
||||||
# registry/src is omitted too: cargo re-extracts it for free from
|
|
||||||
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry/index
|
|
||||||
~/.cargo/registry/cache
|
|
||||||
~/.cargo/git/db
|
|
||||||
# One shared key across every job. The old per-job keys existed to stop
|
|
||||||
# debug/release target artifacts clobbering each other; with target no
|
|
||||||
# longer cached, registry contents are target-independent, so all jobs
|
|
||||||
# want the same crates. First job to finish saves; the rest restore.
|
|
||||||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-registry-
|
|
||||||
|
|
||||||
- name: Cache Windows CRT/SDK (cargo-xwin)
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: ~/.cache/cargo-xwin
|
|
||||||
# Contents track the xwin version baked into the builder image, not our
|
|
||||||
# lockfile -- keying this on Cargo.lock re-downloaded the whole SDK on
|
|
||||||
# every release bump. Bump the suffix by hand if the image's xwin moves.
|
|
||||||
key: ${{ runner.os }}-cargo-xwin-v1
|
|
||||||
|
|
||||||
- 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-
|
|
||||||
|
|
||||||
# The tag is the single source of truth for a release version; the script
|
|
||||||
# stamps every file that carries it (package.json, tauri.conf.json,
|
|
||||||
# Cargo.toml, Cargo.lock). This step used to sed only tauri.conf.json, so
|
|
||||||
# the other three shipped whatever was committed.
|
|
||||||
- name: Set app version from tag
|
|
||||||
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
|
|
||||||
- name: Build Windows (NSIS installer + exe)
|
|
||||||
run: OUTPUT_DIR="$PWD/dist/windows" WIN_BUNDLES=nsis ./scripts/build-windows-cross.sh
|
|
||||||
env:
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
|
||||||
|
|
||||||
- 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: 7
|
|
||||||
|
|
||||||
build-android:
|
|
||||||
name: Build Android
|
|
||||||
runs-on: linux/amd64
|
|
||||||
needs: test
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
|
||||||
env:
|
|
||||||
ANDROID_HOME: /opt/android-sdk
|
|
||||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
|
||||||
ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Cache Rust dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
# Registry only -- never src-tauri/target. That directory is ~16 GB and
|
|
||||||
# was cached under five separate keys, which filled the runner's 74 GB
|
|
||||||
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
|
|
||||||
# registry/src is omitted too: cargo re-extracts it for free from
|
|
||||||
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry/index
|
|
||||||
~/.cargo/registry/cache
|
|
||||||
~/.cargo/git/db
|
|
||||||
# One shared key across every job. The old per-job keys existed to stop
|
|
||||||
# debug/release target artifacts clobbering each other; with target no
|
|
||||||
# longer cached, registry contents are target-independent, so all jobs
|
|
||||||
# want the same crates. First job to finish saves; the rest restore.
|
|
||||||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-registry-
|
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.bun/install/cache
|
|
||||||
node_modules
|
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
|
|
||||||
# Stamp before `android init`: it derives its generated project (including
|
|
||||||
# the initial versionCode) from tauri.conf.json.
|
|
||||||
- name: Set app version from tag
|
|
||||||
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
|
|
||||||
- name: Initialize Android project
|
|
||||||
run: bun run tauri android init
|
|
||||||
|
|
||||||
# Re-run after init: tauri.properties only exists now, and its
|
|
||||||
# autogenerated versionCode (0.0.15 -> 15) is both tiny and NOT monotonic
|
|
||||||
# against the 1000 floor already shipped in the field. The script rewrites
|
|
||||||
# it as 1000 + major*10000 + minor*100 + patch. Runs unconditionally so
|
|
||||||
# untagged builds get a sane code too, derived from git describe.
|
|
||||||
- name: Pin a monotonic Android versionCode
|
|
||||||
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
|
||||||
|
|
||||||
- name: Sync custom Android sources & gradle config
|
|
||||||
run: ./scripts/sync-android-sources.sh
|
|
||||||
|
|
||||||
- name: Write signing keystore
|
|
||||||
run: |
|
|
||||||
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > "$RUNNER_TEMP/jellytau-release.jks"
|
|
||||||
cat > src-tauri/gen/android/keystore.properties <<EOF
|
|
||||||
storeFile=$RUNNER_TEMP/jellytau-release.jks
|
|
||||||
storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
||||||
keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
|
|
||||||
keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# `--apk` is a boolean flag, not `--apk true`. tauri-cli took a value here
|
|
||||||
# until 2.10; from 2.11 the stray `true` is parsed as a positional and the
|
|
||||||
# command fails with "unexpected argument 'true' found" before building.
|
|
||||||
# This line and scripts/build-android.sh must agree.
|
|
||||||
- name: Build signed Android APK
|
|
||||||
run: bun run tauri android build --apk --target aarch64
|
|
||||||
|
|
||||||
- name: Collect & verify signed APK
|
|
||||||
run: |
|
|
||||||
mkdir -p dist/android
|
|
||||||
APK=$(find src-tauri/gen/android/app/build/outputs/apk -name '*-release.apk' | head -1)
|
|
||||||
if [ -z "$APK" ]; then echo "❌ No release APK produced"; exit 1; fi
|
|
||||||
cp "$APK" dist/android/jellytau-release.apk
|
|
||||||
APKSIGNER=$(find "$ANDROID_SDK_ROOT/build-tools" -name apksigner | sort -V | tail -1)
|
|
||||||
echo "🔏 Verifying signature with $APKSIGNER"
|
|
||||||
"$APKSIGNER" verify --print-certs dist/android/jellytau-release.apk
|
|
||||||
ls -lah dist/android/
|
|
||||||
|
|
||||||
- name: Upload Android build artifact
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: jellytau-android
|
|
||||||
path: dist/android/
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
create-release:
|
|
||||||
name: Create Release
|
|
||||||
runs-on: linux/amd64
|
|
||||||
needs: [build-linux, build-windows, build-android]
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Get version from tag
|
|
||||||
id: tag_name
|
|
||||||
run: |
|
|
||||||
echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
|
||||||
echo "RELEASE_NAME=JellyTau ${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Download Linux artifacts
|
|
||||||
uses: actions/download-artifact@v3
|
|
||||||
with:
|
|
||||||
name: jellytau-linux
|
|
||||||
path: artifacts/linux/
|
|
||||||
|
|
||||||
- name: Download Windows artifacts
|
|
||||||
uses: actions/download-artifact@v3
|
|
||||||
with:
|
|
||||||
name: jellytau-windows
|
|
||||||
path: artifacts/windows/
|
|
||||||
|
|
||||||
- name: Download Android artifacts
|
|
||||||
uses: actions/download-artifact@v3
|
|
||||||
with:
|
|
||||||
name: jellytau-android
|
|
||||||
path: artifacts/android/
|
|
||||||
|
|
||||||
# Runs before the SBOM, the checksums and the upload -- everything
|
|
||||||
# downstream describes this set of files, so a stale artifact must be
|
|
||||||
# caught before it gets hashed into SHA256SUMS and published as though it
|
|
||||||
# belonged to this release.
|
|
||||||
#
|
|
||||||
# See the script for the eight months of releases that shipped their
|
|
||||||
# predecessors' Windows installers.
|
|
||||||
- name: Verify artifacts belong to this release
|
|
||||||
run: |
|
|
||||||
./scripts/check-release-artifacts.sh \
|
|
||||||
"${{ steps.tag_name.outputs.VERSION }}" \
|
|
||||||
artifacts/linux artifacts/windows artifacts/android
|
|
||||||
|
|
||||||
# Software Bill of Materials, one per half of the app. Without it there is
|
|
||||||
# no answer to "does this release contain <vulnerable crate>?" other than
|
|
||||||
# rebuilding the tag and re-resolving it. cargo-cyclonedx is in the builder
|
|
||||||
# image; the JS side is read straight from the lockfile bun install used.
|
|
||||||
- name: Generate SBOM
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
mkdir -p artifacts/sbom
|
|
||||||
cd src-tauri
|
|
||||||
cargo cyclonedx --format json
|
|
||||||
find . -maxdepth 2 -name "*.cdx.json" -exec cp -v {} ../artifacts/sbom/ \;
|
|
||||||
cd ..
|
|
||||||
bun install --frozen-lockfile
|
|
||||||
bun pm ls --all > artifacts/sbom/frontend-dependencies.txt
|
|
||||||
ls -lah artifacts/sbom/
|
|
||||||
|
|
||||||
# Checksums over everything being published. A release of unsigned Linux
|
|
||||||
# and Windows binaries with no checksum gives a user no way at all to tell
|
|
||||||
# a corrupted or substituted download from a good one — and the AppImage
|
|
||||||
# and NSIS installer are both fetched over plain HTTP redirects.
|
|
||||||
#
|
|
||||||
# Written with paths relative to the asset directory so `sha256sum -c
|
|
||||||
# SHA256SUMS` works in the directory a user downloaded into.
|
|
||||||
# The update manifest. Built before the checksums so latest.json is not
|
|
||||||
# itself hashed into SHA256SUMS (it is metadata about the release, not a
|
|
||||||
# download), and after the artifacts exist so the signatures can be read.
|
|
||||||
#
|
|
||||||
# Why a dedicated `updater` branch and a raw-file URL: this Gitea serves
|
|
||||||
# /releases/download/<tag>/<asset> but returns 404 for
|
|
||||||
# /releases/latest/download/<asset>, so there is no stable "latest release"
|
|
||||||
# URL to point a client at. The gitea-pages branch is force-pushed whole by
|
|
||||||
# publish-docs.yml, so hosting the manifest there would delete it on the
|
|
||||||
# next docs build. An orphan branch that only ever contains latest.json is
|
|
||||||
# the one location both stable and ours.
|
|
||||||
- name: Build update manifest (latest.json)
|
|
||||||
id: manifest
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
VERSION="${{ steps.tag_name.outputs.VERSION }}"
|
|
||||||
# The manifest carries the bare version; the tag carries the v prefix.
|
|
||||||
PLAIN="${VERSION#v}"
|
|
||||||
BASE="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${VERSION}"
|
|
||||||
|
|
||||||
# Tauri matches on "<os>-<arch>". We ship one desktop arch today.
|
|
||||||
APPIMAGE_SIG=""
|
|
||||||
NSIS_SIG=""
|
|
||||||
APPIMAGE_URL=""
|
|
||||||
NSIS_URL=""
|
|
||||||
|
|
||||||
# Tauri v2 signs the AppImage itself; <name>.AppImage.sig sits beside
|
|
||||||
# it. Verified against a real signed build before tagging -- the
|
|
||||||
# v1-style .AppImage.tar.gz is never produced with
|
|
||||||
# createUpdaterArtifacts: true.
|
|
||||||
for f in artifacts/linux/*.AppImage; do
|
|
||||||
[ -e "$f" ] || continue
|
|
||||||
case "$f" in *.sig) continue;; esac
|
|
||||||
APPIMAGE_URL="${BASE}/$(basename "$f")"
|
|
||||||
[ -e "$f.sig" ] && APPIMAGE_SIG="$(cat "$f.sig")"
|
|
||||||
done
|
|
||||||
|
|
||||||
for f in artifacts/windows/*-setup.exe; do
|
|
||||||
[ -e "$f" ] || continue
|
|
||||||
NSIS_URL="${BASE}/$(basename "$f")"
|
|
||||||
[ -e "$f.sig" ] && NSIS_SIG="$(cat "$f.sig")"
|
|
||||||
done
|
|
||||||
|
|
||||||
# A manifest with an empty signature is worse than no manifest: the
|
|
||||||
# client rejects it after downloading the whole payload.
|
|
||||||
if [ -z "$APPIMAGE_SIG" ] || [ -z "$NSIS_SIG" ]; then
|
|
||||||
echo "::error::Missing updater signature (appimage='$APPIMAGE_SIG' nsis='$NSIS_SIG')."
|
|
||||||
echo "::error::Check that TAURI_SIGNING_PRIVATE_KEY reached both desktop build jobs."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# What the in-app update prompt shows. Same reviewed source as the
|
|
||||||
# release body -- the CHANGELOG section for this version, not the
|
|
||||||
# traceability draft.
|
|
||||||
NOTES="$(awk -v ver="## $VERSION" '$0==ver{f=1;next} /^## /{if(f)exit} f' CHANGELOG.md | head -c 4000)"
|
|
||||||
[ -n "$NOTES" ] || NOTES="See the release page for details."
|
|
||||||
|
|
||||||
jq -n \
|
|
||||||
--arg version "$PLAIN" \
|
|
||||||
--arg notes "$NOTES" \
|
|
||||||
--arg pub_date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
||||||
--arg lin_sig "$APPIMAGE_SIG" --arg lin_url "$APPIMAGE_URL" \
|
|
||||||
--arg win_sig "$NSIS_SIG" --arg win_url "$NSIS_URL" \
|
|
||||||
'{
|
|
||||||
version: $version,
|
|
||||||
notes: $notes,
|
|
||||||
pub_date: $pub_date,
|
|
||||||
platforms: {
|
|
||||||
"linux-x86_64": { signature: $lin_sig, url: $lin_url },
|
|
||||||
"windows-x86_64": { signature: $win_sig, url: $win_url }
|
|
||||||
}
|
|
||||||
}' > latest.json
|
|
||||||
|
|
||||||
echo "📄 latest.json:"
|
|
||||||
cat latest.json
|
|
||||||
|
|
||||||
- name: Publish latest.json to the updater branch
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}"
|
|
||||||
HOST="$(echo "$GITHUB_SERVER_URL" | sed -E 's#^https?://##')"
|
|
||||||
REMOTE="https://oauth2:${TOKEN}@${HOST}/${GITHUB_REPOSITORY}.git"
|
|
||||||
|
|
||||||
# Built in a scratch repo, NOT by switching branches in the checkout.
|
|
||||||
# `git checkout --orphan` here would leave every later step standing on
|
|
||||||
# a one-commit branch -- and the next step but one runs
|
|
||||||
# `bun run release:notes`, which resolves a commit range against the
|
|
||||||
# real history and would silently produce nothing.
|
|
||||||
WORK="$RUNNER_TEMP/updater-branch"
|
|
||||||
rm -rf "$WORK"
|
|
||||||
mkdir -p "$WORK"
|
|
||||||
cp latest.json "$WORK/latest.json"
|
|
||||||
cd "$WORK"
|
|
||||||
git init -q
|
|
||||||
git config user.email "ci@jellytau"
|
|
||||||
git config user.name "JellyTau CI"
|
|
||||||
git add latest.json
|
|
||||||
git commit -qm "chore(updater): manifest for ${{ steps.tag_name.outputs.VERSION }}"
|
|
||||||
echo "🚀 Force-pushing update manifest to the updater branch"
|
|
||||||
# Force-push: the branch holds exactly one file and no history worth
|
|
||||||
# keeping, same shape as publish-docs.yml's gitea-pages.
|
|
||||||
git push -f "$REMOTE" HEAD:refs/heads/updater
|
|
||||||
echo "✅ Served at ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/raw/branch/updater/latest.json"
|
|
||||||
|
|
||||||
- name: Generate SHA256SUMS
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
mkdir -p artifacts/release
|
|
||||||
find artifacts/linux artifacts/windows artifacts/android -type f -exec cp -v {} artifacts/release/ \;
|
|
||||||
cd artifacts/release
|
|
||||||
sha256sum * > SHA256SUMS
|
|
||||||
echo "🔐 Published checksums:"
|
|
||||||
cat SHA256SUMS
|
|
||||||
# Verify what we just wrote, so a broken checksum file fails the
|
|
||||||
# release rather than shipping and failing for users.
|
|
||||||
sha256sum -c SHA256SUMS
|
|
||||||
|
|
||||||
# The published body is the hand-written CHANGELOG.md section for this
|
|
||||||
# version. `bun run release:notes` is printed into the job log as a
|
|
||||||
# drafting aid, but is NOT published: CLAUDE.md is explicit that its
|
|
||||||
# output is "a reviewed draft, not a final changelog", and publishing it
|
|
||||||
# unreviewed proved the point -- a range containing a repo-wide prettier
|
|
||||||
# sweep resolved to nearly the whole requirement matrix and produced notes
|
|
||||||
# claiming one release had added the entire application.
|
|
||||||
#
|
|
||||||
# A missing CHANGELOG section fails the release. A release whose notes say
|
|
||||||
# nothing is worse than one that waits for a maintainer to write two
|
|
||||||
# sentences, and the checklist already requires that entry.
|
|
||||||
- name: Prepare release notes
|
|
||||||
id: release_notes
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
VERSION="${{ steps.tag_name.outputs.VERSION }}"
|
|
||||||
|
|
||||||
echo "📋 Traceability draft (for reference; not published):"
|
|
||||||
bun run release:notes 2>/dev/null || echo "(could not derive a draft)"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# The section between this version's heading and the next one.
|
|
||||||
CHANGES=$(awk -v ver="## $VERSION" '$0==ver{f=1;next} /^## /{if(f)exit} f' CHANGELOG.md)
|
|
||||||
if [ -z "$(echo "$CHANGES" | tr -d '[:space:]')" ]; then
|
|
||||||
echo "::error::CHANGELOG.md has no '## $VERSION' section."
|
|
||||||
echo "::error::Add the entry for this version and re-tag; see docs/release-checklist.md."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
{
|
|
||||||
echo "$CHANGES"
|
|
||||||
echo ""
|
|
||||||
echo "### Downloads"
|
|
||||||
echo ""
|
|
||||||
echo "| Platform | File |"
|
|
||||||
echo "|---|---|"
|
|
||||||
echo "| Linux (portable) | \`*.AppImage\` — \`chmod +x\` and run |"
|
|
||||||
echo "| Linux (Debian/Ubuntu) | \`*.deb\` — \`sudo dpkg -i\` |"
|
|
||||||
echo "| Linux (Fedora/openSUSE) | \`*.rpm\` — \`sudo rpm -i\` |"
|
|
||||||
echo "| Windows | \`*-setup.exe\` (NSIS). Unsigned — SmartScreen may warn on first run. |"
|
|
||||||
echo "| Android | \`*.apk\` sideload, or \`*.aab\` for Play Console |"
|
|
||||||
echo ""
|
|
||||||
echo "Desktop builds check for updates from here and can install a new"
|
|
||||||
echo "version in place, verifying its signature first."
|
|
||||||
echo ""
|
|
||||||
echo "### Verifying your download"
|
|
||||||
echo ""
|
|
||||||
echo "\`\`\`bash"
|
|
||||||
echo "sha256sum -c SHA256SUMS"
|
|
||||||
echo "\`\`\`"
|
|
||||||
echo ""
|
|
||||||
echo "\`SHA256SUMS\` covers every file in this release. An SBOM"
|
|
||||||
echo "(\`*.cdx.json\`, \`frontend-dependencies.txt\`) lists what went into it."
|
|
||||||
echo ""
|
|
||||||
echo "### Requirements"
|
|
||||||
echo ""
|
|
||||||
echo "- **Linux:** 64-bit, GLIBC 2.29+"
|
|
||||||
echo "- **Windows:** 64-bit Windows 10 or later"
|
|
||||||
echo "- **Android:** 8.0 or later, ~50 MB free"
|
|
||||||
echo ""
|
|
||||||
echo "---"
|
|
||||||
echo "Report a problem: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/issues"
|
|
||||||
} > release_notes.md
|
|
||||||
|
|
||||||
echo "📝 Release notes:"
|
|
||||||
cat release_notes.md
|
|
||||||
|
|
||||||
- name: Publish Gitea release & upload assets
|
|
||||||
env:
|
|
||||||
# GITEA_TOKEN (a PAT) is preferred; falls back to the auto-provided token.
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
command -v jq >/dev/null || { echo "❌ jq is required on the runner"; exit 1; }
|
|
||||||
VERSION="${{ steps.tag_name.outputs.VERSION }}"
|
|
||||||
API="${GITHUB_SERVER_URL}/api/v1"
|
|
||||||
REPO="${GITHUB_REPOSITORY}"
|
|
||||||
TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}"
|
|
||||||
case "$VERSION" in *rc*|*beta*|*alpha*) PRE=true;; *) PRE=false;; esac
|
|
||||||
|
|
||||||
PAYLOAD=$(jq -n \
|
|
||||||
--arg tag "$VERSION" \
|
|
||||||
--arg name "JellyTau $VERSION" \
|
|
||||||
--rawfile body release_notes.md \
|
|
||||||
--argjson pre "$PRE" \
|
|
||||||
'{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:$pre}')
|
|
||||||
|
|
||||||
echo "📦 Creating release $VERSION on $REPO"
|
|
||||||
# -f drops on HTTP error; capture status so an existing release (409) is handled gracefully.
|
|
||||||
HTTP=$(curl -sS -o resp.json -w '%{http_code}' -X POST "$API/repos/$REPO/releases" \
|
|
||||||
-H "Authorization: token $TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$PAYLOAD")
|
|
||||||
if [ "$HTTP" = "201" ]; then
|
|
||||||
RELEASE_ID=$(jq -r '.id' resp.json)
|
|
||||||
elif [ "$HTTP" = "409" ]; then
|
|
||||||
echo "ℹ️ Release $VERSION already exists; fetching its id to upload assets"
|
|
||||||
RELEASE_ID=$(curl -fsS "$API/repos/$REPO/releases/tags/$VERSION" \
|
|
||||||
-H "Authorization: token $TOKEN" | jq -r '.id')
|
|
||||||
else
|
|
||||||
echo "❌ Failed to create release (HTTP $HTTP):"; cat resp.json; exit 1
|
|
||||||
fi
|
|
||||||
echo "Release id=$RELEASE_ID"
|
|
||||||
|
|
||||||
# artifacts/release/ holds a copy of every platform artifact plus the
|
|
||||||
# SHA256SUMS generated over exactly that set, so the checksums describe
|
|
||||||
# precisely what is uploaded. artifacts/sbom/ rides along.
|
|
||||||
for f in artifacts/release/* artifacts/sbom/*; do
|
|
||||||
[ -f "$f" ] || continue
|
|
||||||
echo "⬆️ Uploading $(basename "$f")"
|
|
||||||
curl -fsS -X POST \
|
|
||||||
"$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$(basename "$f")" \
|
|
||||||
-H "Authorization: token $TOKEN" \
|
|
||||||
-F "attachment=@$f" >/dev/null
|
|
||||||
done
|
|
||||||
echo "✅ Release $VERSION published with assets"
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
name: '📱 Test APK'
|
|
||||||
|
|
||||||
# Installable Android builds that are not releases.
|
|
||||||
#
|
|
||||||
# Two ways in:
|
|
||||||
#
|
|
||||||
# push to master -> refreshes the rolling `latest` pre-release, so there is
|
|
||||||
# always a current APK behind one stable URL that can be
|
|
||||||
# handed to a tester once and never re-sent.
|
|
||||||
# workflow_dispatch -> builds any branch on demand, optionally publishing it
|
|
||||||
# as `test-<branch>`.
|
|
||||||
#
|
|
||||||
# Why this is separate from build-release.yml: that workflow is tag-driven,
|
|
||||||
# builds Linux + Windows + Android and creates a real release. This produces one
|
|
||||||
# APK and never touches the release channel.
|
|
||||||
#
|
|
||||||
# What comes out installs as com.dtourolle.jellytau.debug ("JellyTau Debug"),
|
|
||||||
# side by side with a real install and with its own data directory. It is a
|
|
||||||
# fully R8-minified release build -- minification is where Android builds have
|
|
||||||
# actually broken here (R8 stripping JNI-loaded player and security classes),
|
|
||||||
# and a plain debug build cannot catch that -- but it is signed with the debug
|
|
||||||
# keystore rather than the store key. So a bad master commit can never replace
|
|
||||||
# somebody's working install, and the production signing key stays in the
|
|
||||||
# tag-driven workflow where it belongs.
|
|
||||||
#
|
|
||||||
# Getting the APK to somebody else: Gitea artifacts need an account with read
|
|
||||||
# access to download, so published builds are attached to a pre-release, whose
|
|
||||||
# assets are a plain public URL. That is the only way an outside tester gets the
|
|
||||||
# file without being given an account.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
paths-ignore:
|
|
||||||
- '**/*.md'
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
variant:
|
|
||||||
description: 'Which build to produce'
|
|
||||||
required: true
|
|
||||||
default: 'side-by-side-release'
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
# R8-minified, exactly what ships, in the debug slot.
|
|
||||||
- side-by-side-release
|
|
||||||
# Unminified. Faster, readable stack traces, but does not exercise
|
|
||||||
# minification at all.
|
|
||||||
- debug
|
|
||||||
abi:
|
|
||||||
description: 'Target ABI'
|
|
||||||
required: true
|
|
||||||
default: 'aarch64'
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- aarch64
|
|
||||||
- armv7
|
|
||||||
- x86_64
|
|
||||||
publish:
|
|
||||||
description: 'Also publish as a pre-release (automatic on master)'
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
# One APK build at a time, and a newer push supersedes an in-flight one — so a
|
|
||||||
# burst of commits to master costs one build, not one per commit. This matters:
|
|
||||||
# the runner has a single slot shared with two other projects.
|
|
||||||
group: build-test-apk
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
env:
|
|
||||||
# Incremental state is never reused between CI runs -- pure disk cost.
|
|
||||||
CARGO_INCREMENTAL: 0
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build test APK
|
|
||||||
runs-on: linux/amd64
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
# This runner executes `run:` blocks with `sh` (dash) unless told
|
|
||||||
# otherwise, so bash-only syntax fails with a bare "Bad substitution"
|
|
||||||
# naming a temp file and no line of your workflow. Say bash explicitly.
|
|
||||||
# The short-SHA output below avoids depending on it regardless.
|
|
||||||
shell: bash
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
|
||||||
env:
|
|
||||||
ANDROID_HOME: /opt/android-sdk
|
|
||||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
|
||||||
ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
# set-version.sh derives a dev version from `git describe --tags`, so
|
|
||||||
# the tags have to be here. A shallow checkout yields 0.0.0.
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
# One place decides what this run is, so the build, the collect step and
|
|
||||||
# the publish step cannot disagree about it. A push carries no dispatch
|
|
||||||
# inputs at all -- every `github.event.inputs.*` is empty on that event --
|
|
||||||
# so each value needs an explicit default rather than being read raw.
|
|
||||||
- name: Resolve build parameters
|
|
||||||
id: cfg
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
VARIANT="${{ github.event.inputs.variant }}"
|
|
||||||
ABI="${{ github.event.inputs.abi }}"
|
|
||||||
PUBLISH="${{ github.event.inputs.publish }}"
|
|
||||||
BRANCH="${GITHUB_REF#refs/heads/}"
|
|
||||||
|
|
||||||
VARIANT="${VARIANT:-side-by-side-release}"
|
|
||||||
ABI="${ABI:-aarch64}"
|
|
||||||
|
|
||||||
# A push to master always publishes -- that is the whole point of a
|
|
||||||
# rolling `latest`. A dispatch publishes only if asked. Compared
|
|
||||||
# against the string 'true' rather than used as a bare truthiness
|
|
||||||
# test: dispatch inputs arrive as strings, and every non-empty string
|
|
||||||
# is truthy, so `if: inputs.publish` would publish even when the box
|
|
||||||
# was deliberately left unticked.
|
|
||||||
if [ "$GITHUB_EVENT_NAME" = "push" ]; then
|
|
||||||
PUBLISH=true
|
|
||||||
elif [ "$PUBLISH" = "true" ]; then
|
|
||||||
PUBLISH=true
|
|
||||||
else
|
|
||||||
PUBLISH=false
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Master is the rolling channel and keeps one stable tag, so the
|
|
||||||
# download URL a tester was given keeps working. Anything else gets
|
|
||||||
# its own branch-scoped tag.
|
|
||||||
if [ "$BRANCH" = "master" ]; then
|
|
||||||
TAG="latest"
|
|
||||||
RELEASE_NAME="Latest build (master)"
|
|
||||||
else
|
|
||||||
TAG="test-$(echo "$BRANCH" | tr '/' '-')"
|
|
||||||
RELEASE_NAME="Test build: $BRANCH"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Stable asset name for the same reason the tag is stable.
|
|
||||||
ASSET="jellytau-${TAG}.apk"
|
|
||||||
|
|
||||||
# Computed once, with `cut` rather than `${GITHUB_SHA::8}`. The
|
|
||||||
# substring form is bash-only and this runner may hand a step to
|
|
||||||
# `sh`; that cost a 51-minute build which produced a perfectly good
|
|
||||||
# APK and then died formatting the summary table.
|
|
||||||
SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-8)
|
|
||||||
|
|
||||||
{
|
|
||||||
echo "variant=$VARIANT"
|
|
||||||
echo "abi=$ABI"
|
|
||||||
echo "publish=$PUBLISH"
|
|
||||||
echo "tag=$TAG"
|
|
||||||
echo "release_name=$RELEASE_NAME"
|
|
||||||
echo "asset=$ASSET"
|
|
||||||
echo "branch=$BRANCH"
|
|
||||||
echo "short_sha=$SHORT_SHA"
|
|
||||||
} >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
echo "variant=$VARIANT abi=$ABI publish=$PUBLISH tag=$TAG asset=$ASSET"
|
|
||||||
|
|
||||||
- name: Cache Rust dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
# Registry only -- never src-tauri/target. Same reasoning (and the
|
|
||||||
# same key) as every other job: that directory is ~16 GB and caching
|
|
||||||
# it filled the runner's 74 GB disk. Sharing the key means this
|
|
||||||
# workflow restores what the others saved rather than adding a
|
|
||||||
# fourth copy of the registry.
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry/index
|
|
||||||
~/.cargo/registry/cache
|
|
||||||
~/.cargo/git/db
|
|
||||||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-registry-
|
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.bun/install/cache
|
|
||||||
node_modules
|
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
|
|
||||||
# Before `android init`: it derives the generated project (including the
|
|
||||||
# initial versionCode) from tauri.conf.json.
|
|
||||||
- name: Stamp a dev version
|
|
||||||
run: ./scripts/set-version.sh
|
|
||||||
|
|
||||||
- name: Initialize Android project
|
|
||||||
run: bun run tauri android init
|
|
||||||
|
|
||||||
# Again after init: tauri.properties only exists now, and its
|
|
||||||
# autogenerated versionCode is neither large enough nor monotonic against
|
|
||||||
# the 1000 floor already shipped. On a branch this derives from
|
|
||||||
# `git describe`, so a test APK always sorts above the last release.
|
|
||||||
- name: Pin a monotonic Android versionCode
|
|
||||||
run: ./scripts/set-version.sh
|
|
||||||
|
|
||||||
# Built through the same script used locally, rather than a hand-rolled
|
|
||||||
# gradle/tauri invocation. That is what keeps CI and a developer's machine
|
|
||||||
# producing the same thing -- and the script asserts the applicationId the
|
|
||||||
# APK actually carries, which has silently regressed before.
|
|
||||||
- name: Build APK
|
|
||||||
run: |
|
|
||||||
if [ "${{ steps.cfg.outputs.variant }}" = "side-by-side-release" ]; then
|
|
||||||
./scripts/build-android.sh release --debug --abi "${{ steps.cfg.outputs.abi }}"
|
|
||||||
else
|
|
||||||
./scripts/build-android.sh debug --abi "${{ steps.cfg.outputs.abi }}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Collect APK
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
mkdir -p dist/test-apk
|
|
||||||
if [ "${{ steps.cfg.outputs.variant }}" = "side-by-side-release" ]; then
|
|
||||||
PATTERN='*-release.apk'
|
|
||||||
else
|
|
||||||
PATTERN='*-debug.apk'
|
|
||||||
fi
|
|
||||||
APK=$(find src-tauri/gen/android/app/build/outputs/apk -name "$PATTERN" | head -1)
|
|
||||||
if [ -z "$APK" ]; then
|
|
||||||
echo "❌ No APK produced for variant ${{ steps.cfg.outputs.variant }}"
|
|
||||||
find src-tauri/gen/android/app/build/outputs/apk -name '*.apk' || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
OUT="dist/test-apk/${{ steps.cfg.outputs.asset }}"
|
|
||||||
cp "$APK" "$OUT"
|
|
||||||
|
|
||||||
# Report what the thing actually is, not what it was meant to be.
|
|
||||||
APKSIGNER=$(find "$ANDROID_SDK_ROOT/build-tools" -name apksigner | sort -V | tail -1)
|
|
||||||
"$APKSIGNER" verify --print-certs "$OUT" || echo "⚠️ Could not verify signature"
|
|
||||||
|
|
||||||
{
|
|
||||||
echo "### 📱 ${{ steps.cfg.outputs.release_name }}"
|
|
||||||
echo ""
|
|
||||||
echo "| | |"
|
|
||||||
echo "|---|---|"
|
|
||||||
echo "| Branch | \`${{ steps.cfg.outputs.branch }}\` |"
|
|
||||||
echo "| Commit | \`${{ steps.cfg.outputs.short_sha }}\` |"
|
|
||||||
echo "| Variant | \`${{ steps.cfg.outputs.variant }}\` |"
|
|
||||||
echo "| ABI | \`${{ steps.cfg.outputs.abi }}\` |"
|
|
||||||
echo "| Size | $(du -h "$OUT" | cut -f1) |"
|
|
||||||
echo "| SHA256 | \`$(sha256sum "$OUT" | cut -d' ' -f1)\` |"
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
|
|
||||||
ls -lah dist/test-apk/
|
|
||||||
|
|
||||||
# Deliberately NOT tagged `v*`: that pattern triggers build-release.yml,
|
|
||||||
# which would run the whole three-platform release matrix and publish a
|
|
||||||
# real release. `latest` and `test-*` carry no version, so nothing else
|
|
||||||
# reacts to them.
|
|
||||||
#
|
|
||||||
# This also cannot reach existing users by itself. The desktop updater
|
|
||||||
# reads a static latest.json from the `updater` branch, not the release
|
|
||||||
# list, so a pre-release published here is invisible to anyone who does
|
|
||||||
# not have the link -- and the APK installs under a different
|
|
||||||
# applicationId anyway.
|
|
||||||
- name: Publish pre-release
|
|
||||||
if: ${{ steps.cfg.outputs.publish == 'true' }}
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
command -v jq >/dev/null || { echo "❌ jq is required on the runner"; exit 1; }
|
|
||||||
API="${GITHUB_SERVER_URL}/api/v1"
|
|
||||||
REPO="${GITHUB_REPOSITORY}"
|
|
||||||
TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}"
|
|
||||||
TAG="${{ steps.cfg.outputs.tag }}"
|
|
||||||
ASSET="${{ steps.cfg.outputs.asset }}"
|
|
||||||
|
|
||||||
BODY=$(printf '%s\n' \
|
|
||||||
"Automatic build of \`${{ steps.cfg.outputs.branch }}\` at \`${{ steps.cfg.outputs.short_sha }}\` — **not a release**." \
|
|
||||||
"" \
|
|
||||||
"Installs as **JellyTau Debug** (\`com.dtourolle.jellytau.debug\`), alongside a" \
|
|
||||||
"normal install and with its own separate data. It cannot replace or upgrade a" \
|
|
||||||
"real install, and uninstalling it does not touch one." \
|
|
||||||
"" \
|
|
||||||
"R8-minified like a real release, but signed with a debug key — so Android will" \
|
|
||||||
"warn about an unknown source. That is expected." \
|
|
||||||
"" \
|
|
||||||
"Variant: \`${{ steps.cfg.outputs.variant }}\` · ABI: \`${{ steps.cfg.outputs.abi }}\`" \
|
|
||||||
"" \
|
|
||||||
"This release is refreshed on every push; the download link stays the same.")
|
|
||||||
|
|
||||||
PAYLOAD=$(jq -n \
|
|
||||||
--arg tag "$TAG" \
|
|
||||||
--arg name "${{ steps.cfg.outputs.release_name }}" \
|
|
||||||
--arg body "$BODY" \
|
|
||||||
--arg target "$GITHUB_SHA" \
|
|
||||||
'{tag_name:$tag, target_commitish:$target, name:$name, body:$body, draft:false, prerelease:true}')
|
|
||||||
|
|
||||||
HTTP=$(curl -sS -o resp.json -w '%{http_code}' -X POST "$API/repos/$REPO/releases" \
|
|
||||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" -d "$PAYLOAD")
|
|
||||||
|
|
||||||
if [ "$HTTP" = "201" ]; then
|
|
||||||
RELEASE_ID=$(jq -r '.id' resp.json)
|
|
||||||
elif [ "$HTTP" = "409" ]; then
|
|
||||||
# The rolling case: reuse the release, refresh its body to name the
|
|
||||||
# new commit, and clear the old asset so `latest` means latest.
|
|
||||||
RELEASE_ID=$(curl -fsS "$API/repos/$REPO/releases/tags/$TAG" \
|
|
||||||
-H "Authorization: token $TOKEN" | jq -r '.id')
|
|
||||||
echo "ℹ️ Refreshing existing pre-release $TAG (id=$RELEASE_ID)"
|
|
||||||
curl -fsS -X PATCH "$API/repos/$REPO/releases/$RELEASE_ID" \
|
|
||||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d "$PAYLOAD" >/dev/null
|
|
||||||
for id in $(curl -fsS "$API/repos/$REPO/releases/$RELEASE_ID/assets" \
|
|
||||||
-H "Authorization: token $TOKEN" | jq -r '.[].id'); do
|
|
||||||
curl -fsS -X DELETE "$API/repos/$REPO/releases/$RELEASE_ID/assets/$id" \
|
|
||||||
-H "Authorization: token $TOKEN" >/dev/null
|
|
||||||
done
|
|
||||||
else
|
|
||||||
echo "❌ Failed to create pre-release (HTTP $HTTP):"; cat resp.json; exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# The tag moves with the branch, so an old tag object would otherwise
|
|
||||||
# keep `latest` pointing at a stale commit.
|
|
||||||
curl -fsS -X POST \
|
|
||||||
"$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$ASSET" \
|
|
||||||
-H "Authorization: token $TOKEN" -F "attachment=@dist/test-apk/$ASSET" >/dev/null
|
|
||||||
|
|
||||||
URL="${GITHUB_SERVER_URL}/${REPO}/releases/download/${TAG}/${ASSET}"
|
|
||||||
{
|
|
||||||
echo ""
|
|
||||||
echo "**Published:** ${GITHUB_SERVER_URL}/${REPO}/releases/tag/${TAG}"
|
|
||||||
echo ""
|
|
||||||
echo "Direct download (stable link, no account needed):"
|
|
||||||
echo ""
|
|
||||||
echo " $URL"
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
echo "✅ Published $TAG -> $URL"
|
|
||||||
|
|
||||||
- name: Upload APK artifact
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: jellytau-test-apk
|
|
||||||
path: dist/test-apk/
|
|
||||||
retention-days: 7
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
name: Publish Documentation
|
|
||||||
|
|
||||||
# Renders the markdown docs (docs/*.md) into an mdBook site, builds the Rust
|
|
||||||
# API reference with cargo doc, and force-pushes the combined output to the
|
|
||||||
# orphan `gitea-pages` branch that the Gitea Pages server serves.
|
|
||||||
#
|
|
||||||
# The published matrix is regenerated during the build, so it is never stale.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
# Only one docs publish at a time; a newer push supersedes an in-flight run.
|
|
||||||
group: publish-docs
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish-docs:
|
|
||||||
name: Build & publish docs to gitea-pages
|
|
||||||
runs-on: linux/amd64
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
# bun is baked into jellytau-builder (see Dockerfile.builder); no setup-bun
|
|
||||||
# action needed — fetching it stalls on this Gitea runner.
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
|
|
||||||
# mdBook is baked into jellytau-builder (Dockerfile.builder, MDBOOK_VERSION).
|
|
||||||
# It used to be curl'd from GitHub releases straight into /usr/local/bin
|
|
||||||
# right here, which was a toolchain install at job time — the exact thing
|
|
||||||
# CLAUDE.md's 🔴 rule forbids — and made every docs publish depend on
|
|
||||||
# GitHub's CDN answering. To move the version, bump it in the image.
|
|
||||||
- name: Confirm mdBook is present
|
|
||||||
run: mdbook --version
|
|
||||||
|
|
||||||
- name: Regenerate traceability matrix (keep published copy current)
|
|
||||||
run: bun run traces:markdown
|
|
||||||
|
|
||||||
- name: Assemble mdBook sources
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
# mdBook's src is docs/. Drop in the SUMMARY and the generated
|
|
||||||
# intro + API redirect pages (build artifacts, not committed).
|
|
||||||
cp docs-site/SUMMARY.md docs/SUMMARY.md
|
|
||||||
|
|
||||||
cat > docs/README.md <<'EOF'
|
|
||||||
# JellyTau Documentation
|
|
||||||
|
|
||||||
Cross-platform Jellyfin client — business logic in a Rust backend,
|
|
||||||
SvelteKit + TypeScript frontend, talking over Tauri v2 IPC.
|
|
||||||
|
|
||||||
- **[Requirements Specification](requirements.md)** — user, integration, and development requirements.
|
|
||||||
- **[Traceability Matrix](traceability.md)** — generated map from requirements to code (regenerated on every publish).
|
|
||||||
- **[Architecture](architecture/README.md)** — backend, frontend, data flow, platform backends.
|
|
||||||
- **[Rust API Reference](api/index.html)** — rustdoc for the `src-tauri` backend.
|
|
||||||
|
|
||||||
_This site is published automatically from `master` by the `publish-docs` CI job._
|
|
||||||
EOF
|
|
||||||
|
|
||||||
cat > docs/api-redirect.md <<'EOF'
|
|
||||||
# Rust API Reference
|
|
||||||
|
|
||||||
The full backend API reference is generated by `cargo doc` (rustdoc).
|
|
||||||
|
|
||||||
👉 **[Open the Rust API Reference](api/index.html)**
|
|
||||||
EOF
|
|
||||||
|
|
||||||
- name: Build mdBook site
|
|
||||||
run: mdbook build docs-site --dest-dir "$GITHUB_WORKSPACE/site"
|
|
||||||
|
|
||||||
- name: Build Rust API docs (cargo doc)
|
|
||||||
working-directory: src-tauri
|
|
||||||
# --no-deps keeps it to our own crate (fast, focused); document private
|
|
||||||
# items so internal modules/commands appear.
|
|
||||||
run: |
|
|
||||||
cargo doc --no-deps --document-private-items
|
|
||||||
# The backend modules/commands live in the LIB crate (jellytau_lib);
|
|
||||||
# the bin crate (jellytau) is a near-empty shim. Land on the lib.
|
|
||||||
echo '<meta http-equiv="refresh" content="0; url=jellytau_lib/index.html">' \
|
|
||||||
> target/doc/index.html
|
|
||||||
|
|
||||||
- name: Assemble published output
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
mkdir -p "$GITHUB_WORKSPACE/site/api"
|
|
||||||
cp -r src-tauri/target/doc/. "$GITHUB_WORKSPACE/site/api/"
|
|
||||||
# Disable Jekyll processing on the pages branch.
|
|
||||||
touch "$GITHUB_WORKSPACE/site/.nojekyll"
|
|
||||||
ls -la "$GITHUB_WORKSPACE/site"
|
|
||||||
|
|
||||||
- name: Push to gitea-pages branch
|
|
||||||
env:
|
|
||||||
# PAT preferred; falls back to the auto-provided token (same pattern
|
|
||||||
# as build-release.yml).
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}"
|
|
||||||
REPO="${GITHUB_REPOSITORY}"
|
|
||||||
HOST="$(echo "$GITHUB_SERVER_URL" | sed -E 's#^https?://##')"
|
|
||||||
REMOTE="https://oauth2:${TOKEN}@${HOST}/${REPO}.git"
|
|
||||||
|
|
||||||
cd "$GITHUB_WORKSPACE/site"
|
|
||||||
git init -q
|
|
||||||
git config user.name "gitea-actions"
|
|
||||||
git config user.email "actions@gitea.tourolle.paris"
|
|
||||||
git checkout -q -b gitea-pages
|
|
||||||
git add -A
|
|
||||||
# POSIX sh has no ${VAR::N} substring expansion — cut instead.
|
|
||||||
SHORT_SHA="$(printf '%s' "$GITHUB_SHA" | cut -c1-8)"
|
|
||||||
git commit -q -m "docs: publish site from ${SHORT_SHA}"
|
|
||||||
echo "🚀 Force-pushing to gitea-pages"
|
|
||||||
git push -f "$REMOTE" gitea-pages
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
name: Traceability Validation
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
- main
|
|
||||||
- develop
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
- main
|
|
||||||
- develop
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
validate-traces:
|
|
||||||
runs-on: linux/amd64
|
|
||||||
name: Check Requirement Traces
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
# bun is baked into jellytau-builder (see Dockerfile.builder); no setup-bun
|
|
||||||
# action needed — fetching it stalls on this Gitea runner.
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
|
|
||||||
- name: Extract traces
|
|
||||||
run: |
|
|
||||||
echo "🔍 Extracting requirement traces..."
|
|
||||||
bun run traces:json > traces-report.json
|
|
||||||
|
|
||||||
- name: Validate traces
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "📊 Validating requirement traceability..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Denominators come from docs/requirements.md at run time — NEVER
|
|
||||||
# hardcode them here. This step previously divided by frozen literals
|
|
||||||
# (UR/39, IR/24, DR/48, JA/3, total 114) while the file had grown to
|
|
||||||
# 211 requirements, so it reported 158% coverage and the threshold
|
|
||||||
# below could never trip. See docs/traceability-ci.md.
|
|
||||||
TOTAL_TRACES=$(jq '.totalTraces' traces-report.json)
|
|
||||||
COVERED=$(jq '.coverage.covered' traces-report.json)
|
|
||||||
TOTAL_REQS=$(jq '.coverage.total' traces-report.json)
|
|
||||||
COVERAGE=$(jq '.coverage.percent' traces-report.json)
|
|
||||||
|
|
||||||
echo "✅ TRACES Found: $TOTAL_TRACES"
|
|
||||||
echo ""
|
|
||||||
echo "📋 Coverage Summary (traced / defined):"
|
|
||||||
for T in UR IR DR JA; do
|
|
||||||
TRACED=$(jq --arg t "$T" '[.byType[$t][] | select(. != null)] | length' traces-report.json)
|
|
||||||
DEFINED=$(jq --arg t "$T" '.defined[$t]' traces-report.json)
|
|
||||||
echo " $T: $TRACED / $DEFINED"
|
|
||||||
done
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo "📈 Overall Coverage: $COVERED / $TOTAL_REQS ($COVERAGE%)"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Traced IDs that requirements.md does not define (typo, or a deleted
|
|
||||||
# requirement). These do not count toward coverage.
|
|
||||||
ORPHANED=$(jq -c '.coverage.orphaned' traces-report.json)
|
|
||||||
if [ "$ORPHANED" != "[]" ]; then
|
|
||||||
echo "⚠️ Traced but not defined in requirements.md: $ORPHANED"
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# A ratio above 100% means the computation is broken — the exact
|
|
||||||
# condition that hid the stale-denominator bug. Fail loudly.
|
|
||||||
if [ "$COVERAGE" -gt 100 ]; then
|
|
||||||
echo "❌ ERROR: Coverage ($COVERAGE%) exceeds 100% — the gate is miscomputing."
|
|
||||||
echo " Orphaned IDs: $ORPHANED"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Minimum coverage. RATCHET POLICY: this number only ever goes UP.
|
|
||||||
#
|
|
||||||
# It sits a few points under the coverage actually achieved, so a real
|
|
||||||
# regression trips it. It was 50 while true coverage was 86%, which
|
|
||||||
# meant nearly half the matrix could rot before CI said a word — a
|
|
||||||
# gate that cannot fail is not a gate.
|
|
||||||
#
|
|
||||||
# When coverage rises durably, raise this to just under the new figure
|
|
||||||
# (`bun run traces:coverage` prints it). Never lower it to make a red
|
|
||||||
# build pass — add the missing TRACES comments instead.
|
|
||||||
#
|
|
||||||
# Keep in sync with MIN_COVERAGE_PERCENT in scripts/extract-traces.ts;
|
|
||||||
# scripts/extract-traces.test.ts fails if the two drift apart.
|
|
||||||
MIN_THRESHOLD=89
|
|
||||||
if [ "$COVERAGE" -lt "$MIN_THRESHOLD" ]; then
|
|
||||||
echo "❌ ERROR: Coverage ($COVERAGE%) is below minimum threshold ($MIN_THRESHOLD%)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ Coverage is acceptable ($COVERAGE% >= $MIN_THRESHOLD%)"
|
|
||||||
|
|
||||||
# Every ID named by a TRACES comment must be defined as a table row in
|
|
||||||
# docs/requirements.md. The extractor used to accept any well-formed ID
|
|
||||||
# silently, so a typo or a rename that missed a call site passed CI
|
|
||||||
# unnoticed (DR-189 and UT-188 lived in three source files, defined
|
|
||||||
# nowhere, for months). This covers UT/IT too, which the coverage
|
|
||||||
# orphan list above deliberately ignores.
|
|
||||||
- name: Validate requirement IDs
|
|
||||||
run: bun run traces:validate
|
|
||||||
|
|
||||||
- name: Check modified files
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
run: |
|
|
||||||
echo "🔍 Checking modified files for traces..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Get changed files
|
|
||||||
CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.(ts|tsx|svelte|rs)$' || echo "")
|
|
||||||
|
|
||||||
if [ -z "$CHANGED" ]; then
|
|
||||||
echo "✅ No TypeScript/Rust files changed"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "📝 Changed files:"
|
|
||||||
echo "$CHANGED" | sed 's/^/ /'
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check each file
|
|
||||||
# Pipe into the loop instead of a here-string (<<<) so this step works
|
|
||||||
# under POSIX sh/dash, not just bash. Use `case` instead of `[[ == ]]`
|
|
||||||
# for the same reason. The loop runs in a subshell (so a counter var
|
|
||||||
# wouldn't survive), so we record warnings in a temp file and count it
|
|
||||||
# afterwards.
|
|
||||||
MISSING_FILE=$(mktemp)
|
|
||||||
echo "$CHANGED" | while IFS= read -r file; do
|
|
||||||
# Skip test files
|
|
||||||
case "$file" in
|
|
||||||
*.test.*) continue ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ -f "$file" ]; then
|
|
||||||
if ! grep -q "TRACES:" "$file"; then
|
|
||||||
echo "⚠️ Missing TRACES: $file"
|
|
||||||
echo "$file" >> "$MISSING_FILE"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
MISSING_TRACES=$(wc -l < "$MISSING_FILE" | tr -d ' ')
|
|
||||||
rm -f "$MISSING_FILE"
|
|
||||||
|
|
||||||
if [ "$MISSING_TRACES" -gt 0 ]; then
|
|
||||||
echo ""
|
|
||||||
echo "📝 Recommendation: Add TRACES comments to new/modified code"
|
|
||||||
echo " Format: // TRACES: UR-001, UR-002 | DR-003"
|
|
||||||
echo ""
|
|
||||||
echo "💡 For more info, see: scripts/README.md"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Generate full report
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
echo "📄 Generating full traceability report..."
|
|
||||||
bun run traces:markdown
|
|
||||||
|
|
||||||
- name: Display report summary
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
echo ""
|
|
||||||
echo "📊 Full Report Generated"
|
|
||||||
echo "📁 Location: docs/traceability.md"
|
|
||||||
echo ""
|
|
||||||
head -50 docs/traceability.md || true
|
|
||||||
|
|
||||||
- name: Save artifacts
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: traceability-reports
|
|
||||||
path: |
|
|
||||||
traces-report.json
|
|
||||||
docs/traceability.md
|
|
||||||
retention-days: 30
|
|
||||||
-67
@@ -1,67 +0,0 @@
|
|||||||
# OS files
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Node.js
|
|
||||||
node_modules
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
|
|
||||||
# Use bun (see packageManager in package.json); ignore other package managers' lockfiles
|
|
||||||
package-lock.json
|
|
||||||
yarn.lock
|
|
||||||
pnpm-lock.yaml
|
|
||||||
|
|
||||||
# Build output
|
|
||||||
/build
|
|
||||||
/dist
|
|
||||||
/.svelte-kit
|
|
||||||
/package
|
|
||||||
|
|
||||||
# Environment variables
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
coverage
|
|
||||||
.nyc_output
|
|
||||||
*.lcov
|
|
||||||
|
|
||||||
# Vitest
|
|
||||||
.vitest
|
|
||||||
|
|
||||||
# Vite
|
|
||||||
vite.config.js.timestamp-*
|
|
||||||
vite.config.ts.timestamp-*
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.idea
|
|
||||||
.vscode
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Android signing keystore (NEVER commit)
|
|
||||||
android-keystore/
|
|
||||||
|
|
||||||
# Local machine-specific Android NDK toolchain paths (do not commit)
|
|
||||||
src-tauri/.cargo/config.toml
|
|
||||||
|
|
||||||
# Docs site build artifacts (generated by the publish-docs CI job into docs/)
|
|
||||||
/docs/SUMMARY.md
|
|
||||||
/docs/README.md
|
|
||||||
/docs/api-redirect.md
|
|
||||||
/docs-site/book/
|
|
||||||
|
|
||||||
# Arch packaging build artifacts (vendored cargo cache, makepkg workdir, output package)
|
|
||||||
/.cargo-arch/
|
|
||||||
/packaging/arch/pkg/
|
|
||||||
/packaging/arch/src/
|
|
||||||
/packaging/arch/*.pkg.tar.zst
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# Dependencies & build output
|
|
||||||
node_modules/
|
|
||||||
.svelte-kit/
|
|
||||||
|
|
||||||
# Scratch worktrees (git-ignored) — full checkouts of this repo
|
|
||||||
.claude/
|
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
coverage/
|
|
||||||
/package/
|
|
||||||
|
|
||||||
# Rust backend (rustfmt owns this tree)
|
|
||||||
src-tauri/
|
|
||||||
|
|
||||||
# Generated by tauri-specta — regenerated on every Rust build, never hand-edited
|
|
||||||
src/lib/api/bindings.ts
|
|
||||||
|
|
||||||
# Lockfiles and generated data
|
|
||||||
bun.lock
|
|
||||||
*.lcov
|
|
||||||
|
|
||||||
# Generated docs (built by the publish-docs CI job)
|
|
||||||
docs/SUMMARY.md
|
|
||||||
docs/README.md
|
|
||||||
docs/api-redirect.md
|
|
||||||
docs-site/book/
|
|
||||||
|
|
||||||
# Hand-maintained Markdown (docs/, CHANGELOG.md, README.md, ...). Prettier
|
|
||||||
# reflows tables and wrapped prose, which would swamp real doc diffs and fight
|
|
||||||
# the hand-tuned layout of docs/requirements.md and docs/traceability.md
|
|
||||||
# (the latter is generated by scripts/extract-traces.ts).
|
|
||||||
**/*.md
|
|
||||||
|
|
||||||
# CI workflow YAML — formatting churn here would obscure real pipeline diffs.
|
|
||||||
.gitea/
|
|
||||||
-20
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json.schemastore.org/prettierrc",
|
|
||||||
"printWidth": 100,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"useTabs": false,
|
|
||||||
"semi": true,
|
|
||||||
"singleQuote": false,
|
|
||||||
"quoteProps": "as-needed",
|
|
||||||
"trailingComma": "all",
|
|
||||||
"bracketSpacing": true,
|
|
||||||
"arrowParens": "always",
|
|
||||||
"endOfLine": "lf",
|
|
||||||
"plugins": ["prettier-plugin-svelte"],
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": "*.svelte",
|
|
||||||
"options": { "parser": "svelte" }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
-1968
File diff suppressed because it is too large
Load Diff
@@ -1,355 +0,0 @@
|
|||||||
# JellyTau
|
|
||||||
|
|
||||||
A cross-platform Jellyfin client. Business logic lives in a Rust backend
|
|
||||||
(`src-tauri/`); a SvelteKit + TypeScript frontend (`src/`) handles presentation
|
|
||||||
and talks to it over Tauri v2 IPC. Targets **Linux** (libmpv, WebKitGTK HTML5
|
|
||||||
`<video>` for transcoded playback) and **Android** (ExoPlayer).
|
|
||||||
|
|
||||||
Package manager is **bun**.
|
|
||||||
|
|
||||||
## Build / Run / Test
|
|
||||||
|
|
||||||
All routine tasks go through `package.json` scripts and helper scripts in
|
|
||||||
`scripts/`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun install # install deps
|
|
||||||
bun run dev # vite dev server (frontend)
|
|
||||||
bun run tauri dev # run the desktop app
|
|
||||||
|
|
||||||
bun run check # svelte-check (types)
|
|
||||||
bun run test # vitest (frontend unit/integration)
|
|
||||||
bun run test:rust # cargo test (scripts/test-rust.sh)
|
|
||||||
bun run test:all # full suite (scripts/test-all.sh)
|
|
||||||
bun run lint # eslint (src/, scripts/, root configs)
|
|
||||||
bun run format:check # prettier
|
|
||||||
|
|
||||||
# Android — canonical entry points (see scripts/):
|
|
||||||
bun run android:build # debug APK
|
|
||||||
bun run android:build:release # release APK
|
|
||||||
bun run android:deploy # install to connected device
|
|
||||||
bun run android:dev # build + deploy
|
|
||||||
bun run android:logs # logcat
|
|
||||||
```
|
|
||||||
|
|
||||||
The **debug** build type carries `applicationIdSuffix ".debug"`, so
|
|
||||||
`com.dtourolle.jellytau.debug` ("JellyTau Debug") installs *alongside* a release
|
|
||||||
build with its own data dir — never uninstall the release app to test a debug
|
|
||||||
one. `./scripts/build-and-deploy.sh release --device --debug` puts an
|
|
||||||
R8-minified *release* build in that same slot, signed with the local debug
|
|
||||||
keystore, for validating minification without the real key. Only the
|
|
||||||
applicationId is suffixed; Kotlin classes stay in the `namespace` package
|
|
||||||
`com.dtourolle.jellytau`, so JNI lookups and R8 keep rules are unaffected. See
|
|
||||||
[README_ANDROID_BUILD.md](src-tauri/android/README_ANDROID_BUILD.md).
|
|
||||||
|
|
||||||
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`, `bun run test`, `bun run format:check` and
|
|
||||||
`bun run lint` (0 errors; the warning count is a CI ratchet) must pass.
|
|
||||||
- Rust: `cd src-tauri && cargo fmt` then `cargo clippy`, plus `bun run test:rust`.
|
|
||||||
- **Boundary**: `bun run check:boundary` must pass — no domain taxonomy (Jellyfin
|
|
||||||
item-type category sets) leaked into the frontend. See below.
|
|
||||||
- **Traceability**: new requirement-implementing code must carry a `// TRACES:`
|
|
||||||
comment (see below).
|
|
||||||
- **Android source edits**: edit `src-tauri/android/src` (the canonical tree),
|
|
||||||
then run `scripts/sync-android-sources.sh` to sync into the `gen/` tree.
|
|
||||||
Never edit the generated `gen/` sources directly.
|
|
||||||
|
|
||||||
## Traceability (TRACES)
|
|
||||||
|
|
||||||
This project practices requirement-driven development: code that implements a
|
|
||||||
requirement is tagged with a `TRACES:` comment linking it to requirement IDs, and
|
|
||||||
an extraction tool builds the traceability matrix. **When you add or change code
|
|
||||||
that implements a requirement, add/update its TRACES comment.** Internal helpers
|
|
||||||
and requirement-less code stay untraced.
|
|
||||||
|
|
||||||
Format — `// TRACES: <URs> | <DRs> | <tests>`, e.g.:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// TRACES: UR-005 | DR-001
|
|
||||||
pub enum PlayerState { … }
|
|
||||||
```
|
|
||||||
```typescript
|
|
||||||
// TRACES: UR-005, UR-026 | DR-029
|
|
||||||
export function autoplayNextEpisode() { }
|
|
||||||
```
|
|
||||||
|
|
||||||
ID types: **UR** user requirement, **IR** integration, **DR** development, **JA**
|
|
||||||
Jellyfin API, **UT** unit test, **IT** integration test. Requirements are defined
|
|
||||||
in [docs/requirements.md](docs/requirements.md); the generated matrix is
|
|
||||||
[docs/traceability.md](docs/traceability.md).
|
|
||||||
|
|
||||||
Tooling:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run traces # extract traces (default format)
|
|
||||||
bun run traces:json # JSON — e.g. | jq '.byType' or '.requirements."UR-005"'
|
|
||||||
bun run traces:markdown # regenerate docs/traceability.md
|
|
||||||
bun run traces:coverage # coverage gate — exits non-zero below the threshold
|
|
||||||
bun run traces:validate # dangling-ID gate — every traced ID must be defined
|
|
||||||
git diff --name-only | xargs grep -L "TRACES:" # find untraced changed files
|
|
||||||
```
|
|
||||||
|
|
||||||
Every ID a `TRACES:` comment names must exist as a table row in
|
|
||||||
`docs/requirements.md` — `traces:validate` fails otherwise, so a typo or a
|
|
||||||
rename that missed a call site can no longer pass silently.
|
|
||||||
|
|
||||||
**CI is Gitea Actions** (`.gitea/workflows/`, remote `gitea.tourolle.paris`), not
|
|
||||||
GitHub. `traceability-check.yml` fails the build if coverage drops below
|
|
||||||
**89%** (`MIN_THRESHOLD`, a *ratchet* — raise it as coverage climbs, never lower
|
|
||||||
it to make a build pass) or if any traced ID is undefined; `build-and-test.yml`
|
|
||||||
runs frontend tests **with coverage thresholds**, `bun run check`, `format:check`,
|
|
||||||
a `--max-warnings` eslint ratchet, Rust tests, `cargo fmt --check`, `cargo clippy
|
|
||||||
-D warnings`, and an Android `cargo check`. See
|
|
||||||
[docs/traceability-ci.md](docs/traceability-ci.md)
|
|
||||||
and [docs/traces-quick-ref.md](docs/traces-quick-ref.md).
|
|
||||||
|
|
||||||
### Traces drive release notes
|
|
||||||
|
|
||||||
Prefer traceability over raw commit subjects when writing release notes for
|
|
||||||
[docs/release-checklist.md](docs/release-checklist.md). Raw `git log` subjects are
|
|
||||||
noisy; the TRACES graph gives a semantic summary of *what capabilities* the
|
|
||||||
release touched.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run release:notes # <latest tag>..HEAD
|
|
||||||
bun run release:notes v0.0.15..HEAD # explicit range
|
|
||||||
```
|
|
||||||
|
|
||||||
[scripts/release-notes.ts](scripts/release-notes.ts) resolves a commit range's
|
|
||||||
changed files → their `TRACES:` IDs → descriptions in
|
|
||||||
[docs/requirements.md](docs/requirements.md), then groups **UR** into *Features*
|
|
||||||
and **DR/IR** into *Improvements* (deduped, so many commits touching one
|
|
||||||
requirement collapse to one line). It also lists changed files that carry no
|
|
||||||
TRACES so nothing is silently dropped — those still need a manual line. Treat the
|
|
||||||
output as a reviewed draft, not a final changelog.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
- **Rust backend** (`src-tauri/src/`) — all business logic: auth, catalog,
|
|
||||||
sessions, downloads, offline cache, playback control. Commands grouped by
|
|
||||||
domain in `src-tauri/src/commands/` (`auth.rs`, `catalog.rs`, `player/`,
|
|
||||||
`download/`, `offline.rs`, `sessions.rs`, …).
|
|
||||||
- **Svelte frontend** (`src/`) — presentation only. Stores in
|
|
||||||
`src/lib/stores/`, API wrappers in `src/lib/api/`, components in
|
|
||||||
`src/lib/components/`.
|
|
||||||
- **Playback layers** — Linux uses libmpv for direct playback and a WebKitGTK
|
|
||||||
HTML5 `<video>` element for HLS-transcoded (h264) streams; Android uses
|
|
||||||
ExoPlayer with a foreground media service + `MediaSessionCompat`.
|
|
||||||
- **tauri-specta** generates TypeScript bindings and typed events from the Rust
|
|
||||||
command/event definitions (registered via the Builder in `src-tauri/src/lib.rs`).
|
|
||||||
|
|
||||||
**Read the architecture docs before making structural changes** — they are the
|
|
||||||
canonical, maintained source; this file only summarizes. See
|
|
||||||
[docs/architecture/README.md](docs/architecture/README.md) and:
|
|
||||||
|
|
||||||
| Doc | Contents |
|
|
||||||
|-----|----------|
|
|
||||||
| [01-rust-backend.md](docs/architecture/01-rust-backend.md) | Player/session state machines, playback mode, queue, commands |
|
|
||||||
| [02-svelte-frontend.md](docs/architecture/02-svelte-frontend.md) | Stores, repository architecture, MiniPlayer, autoplay, nav guard |
|
|
||||||
| [03-data-flow.md](docs/architecture/03-data-flow.md) | Cache-first query flow, playback initiation, mode transfer |
|
|
||||||
| [04-type-sync-and-threading.md](docs/architecture/04-type-sync-and-threading.md) | **Rust↔TS type sync, the IPC camelCase convention + param table, locking** |
|
|
||||||
| [05-platform-backends.md](docs/architecture/05-platform-backends.md) | MpvBackend (Linux), ExoPlayerBackend (Android), MediaSession, HTML5 adapter |
|
|
||||||
| [06-downloads-and-offline.md](docs/architecture/06-downloads-and-offline.md) | Download manager/worker, smart cache, offline commands |
|
|
||||||
| [07-connectivity.md](docs/architecture/07-connectivity.md) | HTTP retry, ConnectivityMonitor, reachability model |
|
|
||||||
| [08-database-design.md](docs/architecture/08-database-design.md) | Tables, relationships, key queries |
|
|
||||||
| [09-security.md](docs/architecture/09-security.md) | Token storage, secure storage, network security |
|
|
||||||
|
|
||||||
Release process lives in [docs/release-checklist.md](docs/release-checklist.md)
|
|
||||||
and [docs/build/build-release.md](docs/build/build-release.md).
|
|
||||||
|
|
||||||
### Core principles (from the architecture docs)
|
|
||||||
|
|
||||||
- **Playback state is one-directional.** The player (ExoPlayer on Android, MPV on
|
|
||||||
Linux, session poller in remote mode) is the **authoritative source** of state
|
|
||||||
— position, pause, seeking, rate, track changes. The Svelte UI, OS
|
|
||||||
`MediaSession`/lockscreen, and MPRIS are **consumers**; they reflect what the
|
|
||||||
player reports and never determine it.
|
|
||||||
- **Unified player boundary.** UI controls playback *only* through the frontend
|
|
||||||
facade `src/lib/player/index.ts` (`playerController`) — never by calling
|
|
||||||
`commands.player*` directly. Webview HTML5 `<video>` reports its state back
|
|
||||||
into Rust via `src/lib/player/html5Adapter.ts` and the `player_report_*`
|
|
||||||
commands, so the controller stays the single source of truth in both native
|
|
||||||
and HTML5 modes.
|
|
||||||
- **Reachability from real traffic.** Server online/offline is derived from the
|
|
||||||
outcome of actual repository requests (reported to `ConnectivityMonitor`), not
|
|
||||||
a side-channel poller. The `/System/Info/Public` probe runs *only while
|
|
||||||
offline*, as a recovery detector.
|
|
||||||
- **Poison-tolerant locking.** Access shared `std::sync` state via the
|
|
||||||
`MutexSafe`/`RwLockSafe` helpers in `utils/lock.rs`, which recover a poisoned
|
|
||||||
lock instead of cascading a panic across the player.
|
|
||||||
- **Graceful backend init.** If a native player backend fails to initialize, the
|
|
||||||
app falls back to a no-op backend and emits `backend-init-failed` rather than
|
|
||||||
crashing.
|
|
||||||
- **Domain vocabulary lives in Rust.** The frontend is presentation-only and must
|
|
||||||
not encode Jellyfin's *taxonomy* — e.g. the set of item types that defines a
|
|
||||||
category like "Music". Send an opaque scope/enum across the boundary and let the
|
|
||||||
backend expand it. Single-type presentation (`itemType: "Movie"`, "this page
|
|
||||||
shows albums") is fine; a *category → set of types* mapping in `src/` is a leak.
|
|
||||||
`bun run check:boundary` is the tripwire; the real gate is the spec's layer
|
|
||||||
assignment. The canonical example lives in Rust:
|
|
||||||
`SearchScope::item_types()` in `repository/types.rs` expands an opaque scope the
|
|
||||||
frontend sends. See [scoped-search-boundary.md](docs/specs/scoped-search-boundary.md)
|
|
||||||
for the incident this rule came from — note the tripwire missed that leak for
|
|
||||||
months because the mapping was assigned to a named const rather than written
|
|
||||||
inline at the query, so **a green `check:boundary` is not proof**; it flags
|
|
||||||
item-type array literals only, not run-time-built sets or `switch`/`||`
|
|
||||||
taxonomy.
|
|
||||||
|
|
||||||
## Writing specs
|
|
||||||
|
|
||||||
New feature specs go in [docs/specs/](docs/specs/) — see its
|
|
||||||
[README](docs/specs/README.md) for the index and what is already built.
|
|
||||||
**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.
|
|
||||||
|
|
||||||
### 🔴 A spec becomes an architecture doc when it ships
|
|
||||||
|
|
||||||
`docs/specs/` holds **only work that has not shipped**. There is no "Implemented"
|
|
||||||
resting state for a spec file: when the last acceptance criterion is met, fold
|
|
||||||
the design into [docs/architecture/](docs/architecture/README.md) and **delete
|
|
||||||
the spec in the same commit**.
|
|
||||||
|
|
||||||
This is not tidying. A directory that mixes promises with descriptions makes both
|
|
||||||
unreliable — you cannot tell from a file whether it describes the build or
|
|
||||||
proposes a change to it, and stale specs then quietly disagree with the code
|
|
||||||
while reading as authority.
|
|
||||||
|
|
||||||
- **Every spec names its destination up front** — the template's "Destination on
|
|
||||||
completion" line. Deciding at spec time which architecture doc will absorb it
|
|
||||||
is a design check in itself: a feature that fits no existing doc is usually a
|
|
||||||
feature whose layer assignment is unclear.
|
|
||||||
- **Carry the reasoning, not the plan.** The architecture doc gets the *why* a
|
|
||||||
future change still needs — invariants, rejected alternatives that would be
|
|
||||||
re-attempted, the defect a piece of code exists to prevent. Acceptance
|
|
||||||
criteria, phase breakdowns and migration steps die with the spec; git history
|
|
||||||
keeps them.
|
|
||||||
- **Deferred work outlives its spec.** Anything the spec listed as out-of-scope
|
|
||||||
and still worth doing goes beside the code it concerns, not into the void.
|
|
||||||
- **Rewrite inbound references before deleting** — source comments and CI
|
|
||||||
scripts cite spec paths, and `check-doc-links` only sees markdown.
|
|
||||||
- **Partially implemented is a real status.** A spec stays until *all* of it
|
|
||||||
ships, with the header naming what is left.
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
### Rust Backend
|
|
||||||
|
|
||||||
- Use `#[tauri::command]` for all IPC handlers.
|
|
||||||
- Prefer `async` commands for I/O-bound work.
|
|
||||||
- Return `Result<T, String>` from commands (the established convention here).
|
|
||||||
- Use `tauri::State<>` for shared state.
|
|
||||||
- Group related commands in domain modules under `commands/`.
|
|
||||||
- Use official Tauri plugins before writing custom native code.
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- Use `invoke<T>()` from `@tauri-apps/api/core`, or the tauri-specta bindings.
|
|
||||||
- Define TS types matching the Rust structs; prefer the generated bindings.
|
|
||||||
- Handle IPC errors with try/catch.
|
|
||||||
- Use `@tauri-apps/api/path` for paths (never hardcode).
|
|
||||||
- Use `@tauri-apps/api/event` for backend→frontend events.
|
|
||||||
|
|
||||||
### 🔴 IPC parameter naming (Tauri v2)
|
|
||||||
|
|
||||||
The command **name** must match the Rust function name exactly
|
|
||||||
(`invoke("player_play_queue", …)`). But **parameter names do NOT** — Tauri v2's
|
|
||||||
`#[tauri::command]` macro auto-converts snake_case Rust params to **camelCase**
|
|
||||||
on the frontend:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn cmd(repository_handle: String) { … }
|
|
||||||
```
|
|
||||||
```typescript
|
|
||||||
await invoke("cmd", { repositoryHandle: "…" }); // camelCase, auto-converted
|
|
||||||
```
|
|
||||||
|
|
||||||
Nested struct fields need `#[serde(rename_all = "camelCase")]`; tagged unions use
|
|
||||||
`#[serde(tag = "type")]` and both sides must match the tag. Note: tauri-specta
|
|
||||||
tagged responses keep the Rust field names as-is (e.g. `new_url`, not `newUrl`).
|
|
||||||
|
|
||||||
### Events
|
|
||||||
|
|
||||||
- Backend events use **kebab-case** names (`download-event`, `search-event`).
|
|
||||||
- Emit from Rust via `emit(...)`; consume on the frontend via
|
|
||||||
`@tauri-apps/api/event` or the tauri-specta typed event bindings.
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|
||||||
- Declare minimum permissions in `src-tauri/capabilities/`.
|
|
||||||
- Keep the CSP restrictive in `tauri.conf.json`.
|
|
||||||
- Validate all inputs in Rust command handlers.
|
|
||||||
- **Never read credentials** (tokens/keys from keyring, env, or stores) without
|
|
||||||
asking the user first.
|
|
||||||
|
|
||||||
## Gotchas (hard-won)
|
|
||||||
|
|
||||||
- **Never call sync/blocking APIs from event callbacks** that can re-enter the
|
|
||||||
player or hold a lock — it deadlocks. On Android, bind a locked
|
|
||||||
`AutoplayDecision` to a `let` *before* matching; a tokio `MutexGuard` held in
|
|
||||||
the `match` scrutinee deadlocks the `AdvanceToNext` arm.
|
|
||||||
- **VideoPlayer native mode**: no lifecycle calls after an `await` in `onMount`
|
|
||||||
(it flips to HTML5 mode and breaks Android seek).
|
|
||||||
- **Transcoded resume/seek**: `get_video_stream_url` must return the HLS
|
|
||||||
`master.m3u8`, not `stream.mp4`, or transcoded playback never starts.
|
|
||||||
- **Downloads** cap at 3 concurrent; the backend pump auto-starts pending rows.
|
|
||||||
Don't loop `startDownload` from the frontend.
|
|
||||||
- **Parallel Claude sessions**: the user may run concurrent sessions. Unexpected
|
|
||||||
file changes may be another session — check `git diff` before "repairing".
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### 🔴 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
|
|
||||||
cd src-tauri && cargo test test_name # single test
|
|
||||||
|
|
||||||
# Frontend
|
|
||||||
bun run test
|
|
||||||
bun run test:coverage
|
|
||||||
|
|
||||||
# Tauri IPC param-naming integration tests (guard the camelCase rule):
|
|
||||||
bun run test -- tauriIntegration.test.ts
|
|
||||||
```
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Code of Conduct
|
|
||||||
|
|
||||||
## The short version
|
|
||||||
|
|
||||||
Be decent to people. Assume the person you are talking to is acting in good
|
|
||||||
faith and knows things you do not.
|
|
||||||
|
|
||||||
## What that means here
|
|
||||||
|
|
||||||
**Expected:**
|
|
||||||
|
|
||||||
- Criticise code, decisions and ideas — not the people who wrote them.
|
|
||||||
- Accept that "no" is a complete answer. This is a small project with a
|
|
||||||
maintainer who has finite time; a declined feature request is not a slight.
|
|
||||||
- Give people room to be new. Everyone was once confused by Tauri's IPC.
|
|
||||||
- Assume a bug report is someone trying to help, even when it arrives terse or
|
|
||||||
frustrated.
|
|
||||||
|
|
||||||
**Not accepted:**
|
|
||||||
|
|
||||||
- Harassment, personal attacks, or demeaning remarks — including about someone's
|
|
||||||
identity, background, or level of experience.
|
|
||||||
- Sexualised language or imagery, and unwelcome attention of any kind.
|
|
||||||
- Publishing someone's private information without their permission.
|
|
||||||
- Persistently derailing discussions, or badgering people who have already
|
|
||||||
answered you.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
This applies in the issue tracker, pull requests, commit messages and any other
|
|
||||||
project space, and to anyone taking part — maintainer included.
|
|
||||||
|
|
||||||
## Reporting
|
|
||||||
|
|
||||||
Email **duncan@tourolle.paris**. Reports are read by the maintainer and handled
|
|
||||||
privately.
|
|
||||||
|
|
||||||
Responses range from a quiet word through to removing comments or blocking an
|
|
||||||
account, depending on what happened. If a report concerns the maintainer, and
|
|
||||||
that makes reporting to them pointless, you are free to say so publicly — a
|
|
||||||
project this size has no separate committee to appeal to, and pretending
|
|
||||||
otherwise would be dishonest.
|
|
||||||
|
|
||||||
## Attribution
|
|
||||||
|
|
||||||
Adapted in spirit from the [Contributor Covenant](https://www.contributor-covenant.org),
|
|
||||||
shortened to what a single-maintainer project can actually honour.
|
|
||||||
-123
@@ -1,123 +0,0 @@
|
|||||||
# Contributing to JellyTau
|
|
||||||
|
|
||||||
Thanks for looking. This file is the short version of how the project is built
|
|
||||||
and what has to be true before a change lands. The long version lives in
|
|
||||||
[CLAUDE.md](CLAUDE.md) and [docs/architecture/](docs/architecture/README.md),
|
|
||||||
which are maintained rather than decorative — read them before a structural
|
|
||||||
change.
|
|
||||||
|
|
||||||
## Getting set up
|
|
||||||
|
|
||||||
Package manager is **bun**. You will also need a Rust toolchain (the exact
|
|
||||||
version is pinned in [src-tauri/rust-toolchain.toml](src-tauri/rust-toolchain.toml)
|
|
||||||
— rustup honours it automatically) and the Tauri Linux dependencies.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun install
|
|
||||||
bun run hooks:install # do this once: it enables the pre-commit gates
|
|
||||||
bun run tauri dev
|
|
||||||
```
|
|
||||||
|
|
||||||
`hooks:install` points `core.hooksPath` at [scripts/hooks/](scripts/hooks/), so
|
|
||||||
hook updates arrive with a `git pull` instead of needing a re-install.
|
|
||||||
|
|
||||||
## What has to pass
|
|
||||||
|
|
||||||
Everything below runs in CI, and the fast half runs in the pre-commit hook. None
|
|
||||||
of it is advisory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run check # svelte-check — 0 errors
|
|
||||||
bun run test # vitest
|
|
||||||
bun run format:check # prettier
|
|
||||||
bun run lint # eslint — 0 errors; the warning count is a ratchet
|
|
||||||
bun run check:boundary # no Jellyfin taxonomy in the frontend
|
|
||||||
bun run test:rust # cargo test
|
|
||||||
cd src-tauri && cargo fmt --all && cargo clippy --all-targets -- -D warnings
|
|
||||||
cd src-tauri && cargo deny check # advisories, licences, bans, sources
|
|
||||||
```
|
|
||||||
|
|
||||||
`bun run test:all` runs the whole set.
|
|
||||||
|
|
||||||
Several of these are **ratchets** — a number that only ever moves in the
|
|
||||||
improving direction:
|
|
||||||
|
|
||||||
| Ratchet | Where | Rule |
|
|
||||||
|---|---|---|
|
|
||||||
| eslint `--max-warnings` | [.gitea/workflows/build-and-test.yml](.gitea/workflows/build-and-test.yml) | only goes down |
|
|
||||||
| Coverage thresholds | [vitest.config.ts](vitest.config.ts) | only go up |
|
|
||||||
| Traceability coverage | [.gitea/workflows/traceability-check.yml](.gitea/workflows/traceability-check.yml) | only goes up |
|
|
||||||
|
|
||||||
Never relax one to make a build pass. Fix the thing it caught.
|
|
||||||
|
|
||||||
## The two rules that surprise people
|
|
||||||
|
|
||||||
**1. Bug fixes start with a failing test.** Write a test that reproduces the bug
|
|
||||||
and *watch it fail* before you touch the fix. A test written against
|
|
||||||
already-fixed code can pass for the wrong reason and guards nothing. If the logic
|
|
||||||
is trapped in a component, extract the pure part into a plain `.ts` module and
|
|
||||||
test that — see `episodeStrip.ts` or `TrackList.logic.ts` for the pattern.
|
|
||||||
|
|
||||||
**2. Domain vocabulary lives in Rust.** The frontend is presentation-only. It
|
|
||||||
must not encode Jellyfin's *taxonomy* — for example, the set of item types that
|
|
||||||
makes up a category like "Music". Send an opaque scope across the IPC boundary
|
|
||||||
and let the backend expand it. `bun run check:boundary` is a tripwire, not a
|
|
||||||
proof: it only flags item-type array literals, so a green run does not mean you
|
|
||||||
are clear. [docs/specs/scoped-search-boundary.md](docs/specs/scoped-search-boundary.md)
|
|
||||||
describes the leak that made this a rule.
|
|
||||||
|
|
||||||
## Traceability
|
|
||||||
|
|
||||||
Code that implements a requirement carries a `TRACES:` comment naming the
|
|
||||||
requirement IDs, and a tool builds the matrix from those comments:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// TRACES: UR-005 | DR-001
|
|
||||||
```
|
|
||||||
|
|
||||||
Every ID must exist as a row in [docs/requirements.md](docs/requirements.md) —
|
|
||||||
`bun run traces:validate` fails on a typo or a stale rename. Internal helpers and
|
|
||||||
requirement-less code stay untraced; do not sprinkle IDs to raise the number.
|
|
||||||
|
|
||||||
If you add a requirement, add its row. If you implement one, tag the code.
|
|
||||||
|
|
||||||
## Commits and pull requests
|
|
||||||
|
|
||||||
- Conventional-commit subjects: `fix(player): …`, `feat(updater): …`, `ci: …`.
|
|
||||||
- Explain **why** in the body, not what the diff already shows. The commit log
|
|
||||||
is the main record of why things are the way they are here, and it is used to
|
|
||||||
draft release notes.
|
|
||||||
- One concern per commit. A formatting sweep and a behaviour change in the same
|
|
||||||
commit is unreviewable.
|
|
||||||
- Rebase rather than merge-commit onto `master`.
|
|
||||||
|
|
||||||
## Specs
|
|
||||||
|
|
||||||
New features start from [docs/specs/SPEC-TEMPLATE.md](docs/specs/SPEC-TEMPLATE.md).
|
|
||||||
Its "Layer assignment" section is the point: each piece of logic gets placed in
|
|
||||||
Rust or the frontend *with a reason*. Review against
|
|
||||||
[docs/specs/SPEC-REVIEW-CHECKLIST.md](docs/specs/SPEC-REVIEW-CHECKLIST.md). Do
|
|
||||||
not frame a spec around "no Rust changes required" — correct placement is the
|
|
||||||
goal, not minimal backend churn.
|
|
||||||
|
|
||||||
## CI
|
|
||||||
|
|
||||||
CI is **Gitea Actions** (`.gitea/workflows/`), not GitHub.
|
|
||||||
|
|
||||||
🔴 **CI installs no system tools.** Every build, test and packaging tool must
|
|
||||||
already be in the Docker builder image. If a job needs a tool the image lacks,
|
|
||||||
add it to [Dockerfile.builder](Dockerfile.builder), rebuild and push the image,
|
|
||||||
and pin the new tag — do not `apt-get` it at job time. Details in
|
|
||||||
[docs/build/ci-operations.md](docs/build/ci-operations.md).
|
|
||||||
|
|
||||||
Fetching the project's own declared dependencies (`bun install`, cargo crates,
|
|
||||||
an advisory database) is not a toolchain install and is fine.
|
|
||||||
|
|
||||||
## Reporting bugs
|
|
||||||
|
|
||||||
Use the issue templates. For anything involving playback, include what the
|
|
||||||
platform was, whether the media was streaming or downloaded, and whether it was
|
|
||||||
transcoding — those three answers determine which of several code paths you were
|
|
||||||
actually on.
|
|
||||||
|
|
||||||
Security issues go to [SECURITY.md](SECURITY.md), not the tracker.
|
|
||||||
-156
@@ -1,156 +0,0 @@
|
|||||||
# 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 \
|
|
||||||
ANDROID_HOME=/opt/android-sdk \
|
|
||||||
NDK_VERSION=27.0.11902837 \
|
|
||||||
SDK_VERSION=34 \
|
|
||||||
RUST_BACKTRACE=1 \
|
|
||||||
PATH="/root/.bun/bin:/root/.cargo/bin:$PATH" \
|
|
||||||
CARGO_HOME=/root/.cargo
|
|
||||||
|
|
||||||
# Install system dependencies
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
# Build essentials
|
|
||||||
build-essential \
|
|
||||||
curl \
|
|
||||||
wget \
|
|
||||||
git \
|
|
||||||
ca-certificates \
|
|
||||||
unzip \
|
|
||||||
# JDK for Android
|
|
||||||
openjdk-17-jdk-headless \
|
|
||||||
# Android build tools
|
|
||||||
android-sdk-platform-tools \
|
|
||||||
# Additional development tools
|
|
||||||
pkg-config \
|
|
||||||
libssl-dev \
|
|
||||||
libclang-dev \
|
|
||||||
llvm-dev \
|
|
||||||
# Tauri Linux desktop dependencies (needed for `cargo test` on the host target)
|
|
||||||
libglib2.0-dev \
|
|
||||||
libgtk-3-dev \
|
|
||||||
libwebkit2gtk-4.1-dev \
|
|
||||||
libjavascriptcoregtk-4.1-dev \
|
|
||||||
libsoup-3.0-dev \
|
|
||||||
librsvg2-dev \
|
|
||||||
libayatana-appindicator3-dev \
|
|
||||||
# mpv player library (linked via libmpv-sys)
|
|
||||||
libmpv-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install Node.js 20.x from NodeSource
|
|
||||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
|
||||||
apt-get install -y --no-install-recommends nodejs && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install Bun
|
|
||||||
RUN curl -fsSL https://bun.sh/install | bash && \
|
|
||||||
ln -s /root/.bun/bin/bun /usr/local/bin/bun
|
|
||||||
|
|
||||||
# Install Rust using rustup
|
|
||||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \
|
|
||||||
. $HOME/.cargo/env && \
|
|
||||||
rustup target add aarch64-linux-android && \
|
|
||||||
rustup target add armv7-linux-androideabi && \
|
|
||||||
rustup target add x86_64-linux-android
|
|
||||||
|
|
||||||
# Setup Android SDK
|
|
||||||
RUN mkdir -p $ANDROID_HOME && \
|
|
||||||
mkdir -p /root/.android && \
|
|
||||||
echo '### User Sources for `android` cmd line tool ###' > /root/.android/repositories.cfg && \
|
|
||||||
echo 'count=0' >> /root/.android/repositories.cfg
|
|
||||||
|
|
||||||
# Download and setup Android Command Line Tools
|
|
||||||
RUN wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdline-tools.zip && \
|
|
||||||
unzip -q /tmp/cmdline-tools.zip -d $ANDROID_HOME && \
|
|
||||||
rm /tmp/cmdline-tools.zip && \
|
|
||||||
mkdir -p $ANDROID_HOME/cmdline-tools/latest && \
|
|
||||||
mv $ANDROID_HOME/cmdline-tools/* $ANDROID_HOME/cmdline-tools/latest/ 2>/dev/null || true
|
|
||||||
|
|
||||||
# Setup Android SDK components
|
|
||||||
RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
|
|
||||||
"platforms;android-$SDK_VERSION" \
|
|
||||||
"build-tools;34.0.0" \
|
|
||||||
"ndk;$NDK_VERSION" \
|
|
||||||
--channel=0 2>&1 | grep -v "Warning" || true
|
|
||||||
|
|
||||||
# Set NDK environment variable
|
|
||||||
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
|
|
||||||
|
|
||||||
# Create working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy project files
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Install Node.js dependencies
|
|
||||||
RUN bun install
|
|
||||||
|
|
||||||
# Install Rust dependencies
|
|
||||||
RUN cd src-tauri && cargo fetch && cd ..
|
|
||||||
|
|
||||||
# Build stage - Tests
|
|
||||||
FROM builder AS test
|
|
||||||
WORKDIR /app
|
|
||||||
RUN echo "Running tests..." && \
|
|
||||||
bunx svelte-kit sync && \
|
|
||||||
bun run test && \
|
|
||||||
cd src-tauri && cargo test && cd .. && \
|
|
||||||
echo "All tests passed!"
|
|
||||||
|
|
||||||
# Build stage - APK
|
|
||||||
FROM builder AS android-build
|
|
||||||
WORKDIR /app
|
|
||||||
RUN cd src-tauri && cargo fetch && cd .. && \
|
|
||||||
echo "Building Android APK..." && \
|
|
||||||
bun run build && \
|
|
||||||
bun run tauri android build --apk true && \
|
|
||||||
echo "APK build complete!"
|
|
||||||
|
|
||||||
# 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, cargo-xwin + nsis + the x86_64-pc-windows-msvc rust
|
|
||||||
# target for Windows). ONE source of dependency truth, shared with CI — no
|
|
||||||
# per-stage apt/rustup here.
|
|
||||||
#
|
|
||||||
# NOTE: Windows uses the MSVC target via cargo-xwin, NOT mingw/GNU — the GNU
|
|
||||||
# toolchain cannot bundle an NSIS installer from Linux. See
|
|
||||||
# scripts/build-windows-cross.sh.
|
|
||||||
|
|
||||||
# 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 \
|
|
||||||
android-sdk-platform-tools \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=android-build /app/src-tauri/gen/android/app/build/outputs/apk /app/apk
|
|
||||||
|
|
||||||
VOLUME ["/app/apk"]
|
|
||||||
CMD ["/bin/bash", "-c", "echo 'APK files are available in /app/apk' && ls -lh /app/apk/"]
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# 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"]
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
# JellyTau Builder Image
|
|
||||||
# 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
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
|
||||||
ANDROID_HOME=/opt/android-sdk \
|
|
||||||
NDK_VERSION=27.0.11902837 \
|
|
||||||
SDK_VERSION=36 \
|
|
||||||
BUILD_TOOLS_VERSION=35.0.0 \
|
|
||||||
RUST_BACKTRACE=1 \
|
|
||||||
PATH="/root/.bun/bin:/root/.cargo/bin:$PATH" \
|
|
||||||
CARGO_HOME=/root/.cargo
|
|
||||||
|
|
||||||
# Install system dependencies
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
build-essential \
|
|
||||||
curl \
|
|
||||||
wget \
|
|
||||||
git \
|
|
||||||
ca-certificates \
|
|
||||||
unzip \
|
|
||||||
jq \
|
|
||||||
openjdk-17-jdk-headless \
|
|
||||||
pkg-config \
|
|
||||||
libssl-dev \
|
|
||||||
libclang-dev \
|
|
||||||
llvm-dev \
|
|
||||||
# Tauri Linux desktop dependencies (needed for `cargo test` on the host target)
|
|
||||||
libglib2.0-dev \
|
|
||||||
libgtk-3-dev \
|
|
||||||
libwebkit2gtk-4.1-dev \
|
|
||||||
libjavascriptcoregtk-4.1-dev \
|
|
||||||
libsoup-3.0-dev \
|
|
||||||
librsvg2-dev \
|
|
||||||
libayatana-appindicator3-dev \
|
|
||||||
# mpv player library (linked via libmpv-sys)
|
|
||||||
libmpv-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install Node.js 20.x from NodeSource
|
|
||||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
|
||||||
apt-get install -y --no-install-recommends nodejs && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install Bun
|
|
||||||
RUN curl -fsSL https://bun.sh/install | bash && \
|
|
||||||
ln -s /root/.bun/bin/bun /usr/local/bin/bun
|
|
||||||
|
|
||||||
# Install Rust using rustup, pinned to an exact release.
|
|
||||||
#
|
|
||||||
# 🔴 RUST_VERSION must equal `channel` in src-tauri/rust-toolchain.toml.
|
|
||||||
#
|
|
||||||
# The two are a pair. rust-toolchain.toml is what makes a developer's `cargo
|
|
||||||
# clippy` agree with CI's; this line is what makes the image already contain that
|
|
||||||
# toolchain. If they drift, rustup silently downloads the pinned version the
|
|
||||||
# first time cargo runs inside a job — a toolchain install at job time, which
|
|
||||||
# CLAUDE.md's "🔴 CI installs no system tools" rule forbids (and which costs
|
|
||||||
# ~1min plus a network dependency on every build).
|
|
||||||
#
|
|
||||||
# 🔴 Changing this line does NOT change CI on its own: the image must be
|
|
||||||
# rebuilt and pushed (`scripts/build-builder-image.sh`) before the new pin is
|
|
||||||
# authoritative. Bump rust-toolchain.toml and this line together, rebuild, push,
|
|
||||||
# then merge.
|
|
||||||
#
|
|
||||||
# Was: `sh -s -- -y` (latest stable, whatever it happened to be on rebuild day).
|
|
||||||
ENV RUST_VERSION=1.97.1
|
|
||||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
|
||||||
sh -s -- -y --profile minimal --default-toolchain "$RUST_VERSION" && \
|
|
||||||
. $HOME/.cargo/env && \
|
|
||||||
rustup default "$RUST_VERSION" && \
|
|
||||||
rustup target add aarch64-linux-android && \
|
|
||||||
rustup target add armv7-linux-androideabi && \
|
|
||||||
rustup target add x86_64-linux-android && \
|
|
||||||
rustup component add rustfmt clippy && \
|
|
||||||
rustc --version && \
|
|
||||||
cargo clippy --version
|
|
||||||
|
|
||||||
# Setup Android SDK
|
|
||||||
RUN mkdir -p $ANDROID_HOME && \
|
|
||||||
mkdir -p /root/.android && \
|
|
||||||
echo '### User Sources for `android` cmd line tool ###' > /root/.android/repositories.cfg && \
|
|
||||||
echo 'count=0' >> /root/.android/repositories.cfg
|
|
||||||
|
|
||||||
# Download and setup Android Command Line Tools
|
|
||||||
RUN wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdline-tools.zip && \
|
|
||||||
unzip -q /tmp/cmdline-tools.zip -d $ANDROID_HOME && \
|
|
||||||
rm /tmp/cmdline-tools.zip && \
|
|
||||||
mkdir -p $ANDROID_HOME/cmdline-tools/latest && \
|
|
||||||
mv $ANDROID_HOME/cmdline-tools/* $ANDROID_HOME/cmdline-tools/latest/ 2>/dev/null || true
|
|
||||||
|
|
||||||
# Accept all SDK licenses up front so Gradle can install/use components non-interactively
|
|
||||||
RUN yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME --licenses > /dev/null
|
|
||||||
|
|
||||||
# Install Android SDK components (must match the compileSdk/targetSdk in the generated Gradle project)
|
|
||||||
RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
|
|
||||||
"platform-tools" \
|
|
||||||
"platforms;android-$SDK_VERSION" \
|
|
||||||
"build-tools;$BUILD_TOOLS_VERSION" \
|
|
||||||
"ndk;$NDK_VERSION" \
|
|
||||||
--channel=0 2>&1 | grep -v "Warning" || true
|
|
||||||
|
|
||||||
# Set NDK environment variable
|
|
||||||
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
|
|
||||||
|
|
||||||
# Gradle distribution. `tauri android init` regenerates gen/android with a
|
|
||||||
# wrapper pointing at services.gradle.org, so every Android job would otherwise
|
|
||||||
# download ~130MB of Gradle at build time — slow, and a hard failure when the
|
|
||||||
# CDN hiccups ("Unexpected end of file from server"). Ship the distribution in
|
|
||||||
# the image instead; scripts/sync-android-sources.sh repoints the regenerated
|
|
||||||
# wrapper at this local copy. Keep GRADLE_VERSION in sync with the version
|
|
||||||
# Tauri's generated wrapper requests.
|
|
||||||
ENV GRADLE_VERSION=8.14.3 \
|
|
||||||
GRADLE_HOME=/opt/gradle/gradle-8.14.3
|
|
||||||
RUN mkdir -p /opt/gradle/dist && \
|
|
||||||
wget -q "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" \
|
|
||||||
-O "/opt/gradle/dist/gradle-${GRADLE_VERSION}-bin.zip" && \
|
|
||||||
unzip -q "/opt/gradle/dist/gradle-${GRADLE_VERSION}-bin.zip" -d /opt/gradle && \
|
|
||||||
"$GRADLE_HOME/bin/gradle" --version
|
|
||||||
ENV PATH="$GRADLE_HOME/bin:$PATH"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 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 \
|
|
||||||
# AppImage bundling. linuxdeploy embeds xdg-open into the AppImage and
|
|
||||||
# aborts the whole bundle if it is missing:
|
|
||||||
# failed to bundle project: xdg-open binary not found
|
|
||||||
# It is present on most desktop distros, which is why the AppImage built on
|
|
||||||
# a developer machine and failed here. desktop-file-utils and zsync are the
|
|
||||||
# other two linuxdeploy commonly wants (desktop-file-validate, and zsync for
|
|
||||||
# delta updates), added together so a missing one does not cost another
|
|
||||||
# image rebuild and another failed release build.
|
|
||||||
xdg-utils \
|
|
||||||
desktop-file-utils \
|
|
||||||
zsync \
|
|
||||||
&& 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
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Supply-chain and docs tooling.
|
|
||||||
#
|
|
||||||
# cargo-deny — advisories/licences/bans/sources gate (src-tauri/deny.toml),
|
|
||||||
# run by the `security` job. It fetches the RustSec advisory
|
|
||||||
# database at run time; that is *data*, not a toolchain, so it
|
|
||||||
# does not breach the no-installs-in-CI rule.
|
|
||||||
# cargo-cyclonedx — SBOM for the Rust half of a release.
|
|
||||||
# mdbook — builds the docs site. It used to be curl'd from GitHub
|
|
||||||
# releases *inside* the job (publish-docs.yml), which was both a
|
|
||||||
# breach of that rule and a hard dependency on GitHub's CDN
|
|
||||||
# being up at publish time. Pinned to the version that job used.
|
|
||||||
ENV MDBOOK_VERSION=v0.4.40
|
|
||||||
RUN . $HOME/.cargo/env && \
|
|
||||||
cargo install --locked cargo-deny cargo-cyclonedx && \
|
|
||||||
wget -q "https://github.com/rust-lang/mdBook/releases/download/${MDBOOK_VERSION}/mdbook-${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz" \
|
|
||||||
-O /tmp/mdbook.tar.gz && \
|
|
||||||
tar -xzf /tmp/mdbook.tar.gz -C /usr/local/bin && \
|
|
||||||
rm /tmp/mdbook.tar.gz && \
|
|
||||||
cargo deny --version && \
|
|
||||||
cargo cyclonedx --version && \
|
|
||||||
mdbook --version
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ENTRYPOINT ["/bin/bash"]
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 Duncan Tourolle
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
<h1 align="center">
|
|
||||||
<img src="docs/assets/logo.png" alt="JellyTau logo" width="120" /><br />
|
|
||||||
JellyTau
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript.
|
|
||||||
|
|
||||||
Business logic lives in a Rust backend; a UI-rich Svelte frontend handles
|
|
||||||
presentation and talks to it over Tauri's IPC. Targets Linux (libmpv) and
|
|
||||||
Android (ExoPlayer).
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
This project uses [bun](https://bun.sh) as its package manager.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Activate the Rust environment (fish shell)
|
|
||||||
source "$HOME/.cargo/env.fish"
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
bun install
|
|
||||||
|
|
||||||
# Run in development
|
|
||||||
bun run tauri dev
|
|
||||||
|
|
||||||
# Type-check the frontend
|
|
||||||
bun run check
|
|
||||||
|
|
||||||
# Build for Linux
|
|
||||||
bun run tauri build
|
|
||||||
|
|
||||||
# Build for Android
|
|
||||||
bun run tauri android build
|
|
||||||
```
|
|
||||||
|
|
||||||
For the full set of build, test, and Android helper scripts, see
|
|
||||||
[scripts/README.md](scripts/README.md).
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
| Topic | Location |
|
|
||||||
|-------|----------|
|
|
||||||
| Architecture overview & subsystem docs | [docs/architecture/](docs/architecture/) |
|
|
||||||
| Requirements, traceability & technical debt | [docs/requirements.md](docs/requirements.md) |
|
|
||||||
| Build & release process | [docs/build/build-release.md](docs/build/build-release.md) |
|
|
||||||
| Docker builds | [docs/build/docker.md](docs/build/docker.md) |
|
|
||||||
| Traceability tooling & CI | [docs/traceability.md](docs/traceability.md), [docs/traceability-ci.md](docs/traceability-ci.md) |
|
|
||||||
| Release checklist | [docs/release-checklist.md](docs/release-checklist.md) |
|
|
||||||
| UX flows | [docs/ux-flows.md](docs/ux-flows.md) |
|
|
||||||
| CI operations (builder image, secrets, runner) | [docs/build/ci-operations.md](docs/build/ci-operations.md) |
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
[CONTRIBUTING.md](CONTRIBUTING.md) covers the setup, the gates a change has to
|
|
||||||
pass, and the two rules that catch people out (bug fixes start with a failing
|
|
||||||
test; Jellyfin's taxonomy stays in Rust). Please also read the
|
|
||||||
[Code of Conduct](CODE_OF_CONDUCT.md).
|
|
||||||
|
|
||||||
Found a security problem? Do not open an issue — see [SECURITY.md](SECURITY.md).
|
|
||||||
|
|
||||||
## Verifying a download
|
|
||||||
|
|
||||||
Every release publishes `SHA256SUMS` covering all of its artifacts, plus an SBOM
|
|
||||||
of what went into the build:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sha256sum -c SHA256SUMS
|
|
||||||
```
|
|
||||||
|
|
||||||
Desktop builds update themselves from Settings → Updates, verifying each payload
|
|
||||||
against JellyTau's signing key before installing. Android installs are handled by
|
|
||||||
the system installer, so the app links to the releases page instead.
|
|
||||||
|
|
||||||
## Recommended IDE Setup
|
|
||||||
|
|
||||||
[VS Code](https://code.visualstudio.com/) +
|
|
||||||
[Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) +
|
|
||||||
[Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) +
|
|
||||||
[rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT
|
|
||||||
-60
@@ -1,60 +0,0 @@
|
|||||||
# Security Policy
|
|
||||||
|
|
||||||
## Reporting a vulnerability
|
|
||||||
|
|
||||||
Email **duncan@tourolle.paris** with `[JellyTau security]` in the subject.
|
|
||||||
Please do **not** open a public issue for a vulnerability — JellyTau handles
|
|
||||||
Jellyfin credentials and media, and an unfixed issue in a public tracker is an
|
|
||||||
advisory for everyone running it.
|
|
||||||
|
|
||||||
Include what you have: what the problem is, how to reproduce it, the version and
|
|
||||||
platform, and what you think an attacker could do with it. A rough report is
|
|
||||||
worth more than a polished one that never gets sent.
|
|
||||||
|
|
||||||
You can expect an acknowledgement within a week. If a fix is warranted it will
|
|
||||||
ship in the next release, and you will be credited in the release notes unless
|
|
||||||
you would rather not be.
|
|
||||||
|
|
||||||
## Supported versions
|
|
||||||
|
|
||||||
JellyTau is a single-maintainer project without long-term support branches.
|
|
||||||
**Only the latest release receives fixes.** Desktop builds can update themselves
|
|
||||||
(Settings → Updates); on Android, install the latest APK from the releases page.
|
|
||||||
|
|
||||||
## What is in scope
|
|
||||||
|
|
||||||
The application and its build pipeline:
|
|
||||||
|
|
||||||
- The Tauri backend (`src-tauri/`) and the Svelte frontend (`src/`)
|
|
||||||
- Credential storage — the system keyring and its encrypted-file fallback
|
|
||||||
- The Android player service and its JNI bridge
|
|
||||||
- The loopback media server used for downloaded playback
|
|
||||||
- The release pipeline: artifact signing, the update manifest, the builder image
|
|
||||||
|
|
||||||
**Out of scope:** vulnerabilities in Jellyfin itself (report those to the
|
|
||||||
Jellyfin project), and issues that require an already-compromised device or a
|
|
||||||
malicious server the user deliberately configured and trusted.
|
|
||||||
|
|
||||||
## What the project already does
|
|
||||||
|
|
||||||
Not a guarantee, but so you know what has been considered:
|
|
||||||
|
|
||||||
- **Credentials** never go in plaintext config: the system keyring is used where
|
|
||||||
available, with an AES-GCM encrypted file as fallback (see
|
|
||||||
[docs/architecture/09-security.md](docs/architecture/09-security.md)).
|
|
||||||
- **The webview runs under a restrictive CSP**, and the asset protocol is scoped
|
|
||||||
to the thumbnail cache directory only.
|
|
||||||
- **Path confinement** is enforced on the cache and download roots — a
|
|
||||||
server-supplied id cannot decide where a file lands (DR-210, DR-211).
|
|
||||||
- **Queries and URLs bind or encode their inputs** rather than interpolating
|
|
||||||
them (DR-212).
|
|
||||||
- **Dependencies are scanned on every build** by `cargo deny` against the RustSec
|
|
||||||
advisory database, and licence-checked against an allow-list (DR-216).
|
|
||||||
- **Releases carry `SHA256SUMS` and an SBOM**, so you can verify a download and
|
|
||||||
find out what went into it.
|
|
||||||
- **Desktop updates are signature-verified** against a key held only in CI before
|
|
||||||
anything is installed (DR-217).
|
|
||||||
|
|
||||||
Windows installers are **not** Authenticode-signed — SmartScreen will warn on
|
|
||||||
first run. That is a cost and identity problem, not an oversight; verify the
|
|
||||||
download against `SHA256SUMS` instead.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# Third-party notices
|
|
||||||
|
|
||||||
JellyTau's own source code is licensed under the MIT License (see `LICENSE`).
|
|
||||||
Some builds bundle third-party components under other licences, listed here.
|
|
||||||
|
|
||||||
## Android: FFmpeg audio decoder (GPL-3.0)
|
|
||||||
|
|
||||||
The Android app bundles **`org.jellyfin.media3:media3-ffmpeg-decoder`**, the
|
|
||||||
Jellyfin project's build of the media3 FFmpeg extension, which contains FFmpeg.
|
|
||||||
It lets the player decode AC-3, E-AC-3, DTS and TrueHD audio, which Android does
|
|
||||||
not ship.
|
|
||||||
|
|
||||||
- Licence: **GNU General Public License v3.0**
|
|
||||||
- Source: <https://github.com/jellyfin/jellyfin-androidx-media> (build of
|
|
||||||
<https://github.com/androidx/media>), with FFmpeg from <https://ffmpeg.org>
|
|
||||||
|
|
||||||
Because this component is GPL-3.0, **the Android APK as distributed is subject
|
|
||||||
to the terms of the GPL-3.0**. The complete corresponding source for JellyTau is
|
|
||||||
available in this repository; JellyTau's own code remains available under MIT.
|
|
||||||
|
|
||||||
Desktop builds do not include this component.
|
|
||||||
@@ -1,860 +0,0 @@
|
|||||||
{
|
|
||||||
"lockfileVersion": 1,
|
|
||||||
"configVersion": 1,
|
|
||||||
"workspaces": {
|
|
||||||
"": {
|
|
||||||
"name": "jellytau",
|
|
||||||
"dependencies": {
|
|
||||||
"@tauri-apps/api": "^2.11.1",
|
|
||||||
"@tauri-apps/plugin-log": "2.9.0",
|
|
||||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
|
||||||
"@tauri-apps/plugin-os": "^2.3.2",
|
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
|
||||||
"@tauri-apps/plugin-updater": "2.10.1",
|
|
||||||
"hls.js": "^1.6.15",
|
|
||||||
"svelte-dnd-action": "^0.9.69",
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/js": "^10.0.1",
|
|
||||||
"@sveltejs/adapter-static": "^3.0.6",
|
|
||||||
"@sveltejs/kit": "^2.9.0",
|
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
|
||||||
"@tauri-apps/cli": "^2.11.4",
|
|
||||||
"@testing-library/svelte": "^5.3.1",
|
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
|
||||||
"@vitest/ui": "^4.0.16",
|
|
||||||
"eslint": "^10.8.1",
|
|
||||||
"eslint-config-prettier": "^10.1.8",
|
|
||||||
"eslint-plugin-svelte": "^3.23.0",
|
|
||||||
"globals": "^17.11.0",
|
|
||||||
"happy-dom": "^20.0.11",
|
|
||||||
"jsdom": "^27.4.0",
|
|
||||||
"prettier": "^3.9.6",
|
|
||||||
"prettier-plugin-svelte": "^4.1.1",
|
|
||||||
"svelte": "^5.47.1",
|
|
||||||
"svelte-check": "^4.0.0",
|
|
||||||
"tailwindcss": "^4.1.18",
|
|
||||||
"typescript": "~5.6.2",
|
|
||||||
"typescript-eslint": "^8.67.0",
|
|
||||||
"vite": "^6.0.3",
|
|
||||||
"vitest": "^4.1.10",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"packages": {
|
|
||||||
"@acemir/cssom": ["@acemir/cssom@0.9.30", "", {}, "sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg=="],
|
|
||||||
|
|
||||||
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.1", "", { "dependencies": { "@csstools/css-calc": "^2.1.4", "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "lru-cache": "^11.2.4" } }, "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ=="],
|
|
||||||
|
|
||||||
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.7.6", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.4" } }, "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg=="],
|
|
||||||
|
|
||||||
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
|
|
||||||
|
|
||||||
"@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=="],
|
|
||||||
|
|
||||||
"@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="],
|
|
||||||
|
|
||||||
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="],
|
|
||||||
|
|
||||||
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.0.22", "", {}, "sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw=="],
|
|
||||||
|
|
||||||
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
|
|
||||||
|
|
||||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
|
||||||
|
|
||||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
|
||||||
|
|
||||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
|
||||||
|
|
||||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
|
||||||
|
|
||||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
|
||||||
|
|
||||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
|
||||||
|
|
||||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
|
||||||
|
|
||||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
|
||||||
|
|
||||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
|
||||||
|
|
||||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
|
||||||
|
|
||||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
|
||||||
|
|
||||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
|
||||||
|
|
||||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
|
||||||
|
|
||||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
|
||||||
|
|
||||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
|
||||||
|
|
||||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
|
||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
|
||||||
|
|
||||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="],
|
|
||||||
|
|
||||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
|
||||||
|
|
||||||
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
|
|
||||||
|
|
||||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.7.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw=="],
|
|
||||||
|
|
||||||
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
|
|
||||||
|
|
||||||
"@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="],
|
|
||||||
|
|
||||||
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
|
|
||||||
|
|
||||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
|
|
||||||
|
|
||||||
"@exodus/bytes": ["@exodus/bytes@1.8.0", "", { "peerDependencies": { "@exodus/crypto": "^1.0.0-rc.4" }, "optionalPeers": ["@exodus/crypto"] }, "sha512-8JPn18Bcp8Uo1T82gR8lh2guEOa5KKU/IEKvvdp0sgmi7coPBWf1Doi1EXsGZb2ehc8ym/StJCjffYV+ne7sXQ=="],
|
|
||||||
|
|
||||||
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
|
|
||||||
|
|
||||||
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
|
|
||||||
|
|
||||||
"@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
|
|
||||||
|
|
||||||
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
|
||||||
|
|
||||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
|
||||||
|
|
||||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
|
||||||
|
|
||||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
|
||||||
|
|
||||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
|
||||||
|
|
||||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
|
||||||
|
|
||||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
|
||||||
|
|
||||||
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.54.0", "", { "os": "android", "cpu": "arm" }, "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.54.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.54.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.54.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.54.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.54.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.54.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.54.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.54.0", "", { "os": "none", "cpu": "arm64" }, "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.54.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.54.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg=="],
|
|
||||||
|
|
||||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.8", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA=="],
|
|
||||||
|
|
||||||
"@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.10", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="],
|
|
||||||
|
|
||||||
"@sveltejs/kit": ["@sveltejs/kit@2.49.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.3.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ=="],
|
|
||||||
|
|
||||||
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="],
|
|
||||||
|
|
||||||
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.2", "", { "dependencies": { "obug": "^2.1.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig=="],
|
|
||||||
|
|
||||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
|
|
||||||
|
|
||||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
|
|
||||||
|
|
||||||
"@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="],
|
|
||||||
|
|
||||||
"@tauri-apps/cli": ["@tauri-apps/cli@2.11.4", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.4", "@tauri-apps/cli-darwin-x64": "2.11.4", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", "@tauri-apps/cli-linux-arm64-musl": "2.11.4", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-musl": "2.11.4", "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", "@tauri-apps/cli-win32-x64-msvc": "2.11.4" }, "bin": { "tauri": "tauri.js" } }, "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ=="],
|
|
||||||
|
|
||||||
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.11.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ=="],
|
|
||||||
|
|
||||||
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.11.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A=="],
|
|
||||||
|
|
||||||
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.11.4", "", { "os": "linux", "cpu": "arm" }, "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ=="],
|
|
||||||
|
|
||||||
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.11.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA=="],
|
|
||||||
|
|
||||||
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.11.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw=="],
|
|
||||||
|
|
||||||
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.11.4", "", { "os": "linux", "cpu": "none" }, "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ=="],
|
|
||||||
|
|
||||||
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.11.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ=="],
|
|
||||||
|
|
||||||
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.11.4", "", { "os": "linux", "cpu": "x64" }, "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A=="],
|
|
||||||
|
|
||||||
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.11.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ=="],
|
|
||||||
|
|
||||||
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.11.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA=="],
|
|
||||||
|
|
||||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw=="],
|
|
||||||
|
|
||||||
"@tauri-apps/plugin-log": ["@tauri-apps/plugin-log@2.9.0", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-Ql8okrnsguk0eDq1GvRfttFV5KaeW/7vcao6bdbkXCRJ1+2sWE15ZJvJVEKVANrOKy1mRngqC3IFIAP+wP5qSw=="],
|
|
||||||
|
|
||||||
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.4", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ=="],
|
|
||||||
|
|
||||||
"@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="],
|
|
||||||
|
|
||||||
"@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="],
|
|
||||||
|
|
||||||
"@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.10.1", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA=="],
|
|
||||||
|
|
||||||
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
|
|
||||||
|
|
||||||
"@testing-library/svelte": ["@testing-library/svelte@5.3.1", "", { "dependencies": { "@testing-library/dom": "9.x.x || 10.x.x", "@testing-library/svelte-core": "1.0.0" }, "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", "vite": "*", "vitest": "*" }, "optionalPeers": ["vite", "vitest"] }, "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w=="],
|
|
||||||
|
|
||||||
"@testing-library/svelte-core": ["@testing-library/svelte-core@1.0.0", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" } }, "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ=="],
|
|
||||||
|
|
||||||
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
|
||||||
|
|
||||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
|
||||||
|
|
||||||
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
|
||||||
|
|
||||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
|
||||||
|
|
||||||
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
|
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
|
||||||
|
|
||||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
|
||||||
|
|
||||||
"@types/node": ["@types/node@20.19.27", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug=="],
|
|
||||||
|
|
||||||
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
|
|
||||||
|
|
||||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.67.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/type-utils": "8.67.0", "@typescript-eslint/utils": "8.67.0", "@typescript-eslint/visitor-keys": "8.67.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.67.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ=="],
|
|
||||||
|
|
||||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.67.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/types": "8.67.0", "@typescript-eslint/typescript-estree": "8.67.0", "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w=="],
|
|
||||||
|
|
||||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.67.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.67.0", "@typescript-eslint/types": "^8.67.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw=="],
|
|
||||||
|
|
||||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.67.0", "", { "dependencies": { "@typescript-eslint/types": "8.67.0", "@typescript-eslint/visitor-keys": "8.67.0" } }, "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg=="],
|
|
||||||
|
|
||||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.67.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg=="],
|
|
||||||
|
|
||||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.67.0", "", { "dependencies": { "@typescript-eslint/types": "8.67.0", "@typescript-eslint/typescript-estree": "8.67.0", "@typescript-eslint/utils": "8.67.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q=="],
|
|
||||||
|
|
||||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.67.0", "", {}, "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww=="],
|
|
||||||
|
|
||||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.67.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.67.0", "@typescript-eslint/tsconfig-utils": "8.67.0", "@typescript-eslint/types": "8.67.0", "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw=="],
|
|
||||||
|
|
||||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.67.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/types": "8.67.0", "@typescript-eslint/typescript-estree": "8.67.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A=="],
|
|
||||||
|
|
||||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.67.0", "", { "dependencies": { "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA=="],
|
|
||||||
|
|
||||||
"@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.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="],
|
|
||||||
|
|
||||||
"@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="],
|
|
||||||
|
|
||||||
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="],
|
|
||||||
|
|
||||||
"@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="],
|
|
||||||
|
|
||||||
"@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="],
|
|
||||||
|
|
||||||
"@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="],
|
|
||||||
|
|
||||||
"@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.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="],
|
|
||||||
|
|
||||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
|
||||||
|
|
||||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
|
||||||
|
|
||||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
|
||||||
|
|
||||||
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
|
||||||
|
|
||||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
|
||||||
|
|
||||||
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
|
||||||
|
|
||||||
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
|
||||||
|
|
||||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
|
||||||
|
|
||||||
"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=="],
|
|
||||||
|
|
||||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
|
||||||
|
|
||||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
|
||||||
|
|
||||||
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
|
|
||||||
|
|
||||||
"brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="],
|
|
||||||
|
|
||||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
|
||||||
|
|
||||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
|
||||||
|
|
||||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
|
||||||
|
|
||||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
|
||||||
|
|
||||||
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
|
||||||
|
|
||||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
|
||||||
|
|
||||||
"css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="],
|
|
||||||
|
|
||||||
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
|
||||||
|
|
||||||
"cssstyle": ["cssstyle@5.3.6", "", { "dependencies": { "@asamuzakjp/css-color": "^4.1.1", "@csstools/css-syntax-patches-for-csstree": "^1.0.21", "css-tree": "^3.1.0", "lru-cache": "^11.2.4" } }, "sha512-legscpSpgSAeGEe0TNcai97DKt9Vd9AsAdOL7Uoetb52Ar/8eJm3LIa39qpv8wWzLFlNG4vVvppQM+teaMPj3A=="],
|
|
||||||
|
|
||||||
"data-urls": ["data-urls@6.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.0.0" } }, "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA=="],
|
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
|
||||||
|
|
||||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
|
||||||
|
|
||||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
|
||||||
|
|
||||||
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
|
||||||
|
|
||||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
|
||||||
|
|
||||||
"devalue": ["devalue@5.6.1", "", {}, "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A=="],
|
|
||||||
|
|
||||||
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
|
||||||
|
|
||||||
"enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
|
|
||||||
|
|
||||||
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
|
||||||
|
|
||||||
"es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
|
|
||||||
|
|
||||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
|
||||||
|
|
||||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
|
||||||
|
|
||||||
"eslint": ["eslint@10.8.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ=="],
|
|
||||||
|
|
||||||
"eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="],
|
|
||||||
|
|
||||||
"eslint-plugin-svelte": ["eslint-plugin-svelte@3.23.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.6.1", "@jridgewell/sourcemap-codec": "^1.5.0", "esutils": "^2.0.3", "globals": "^16.0.0", "known-css-properties": "^0.37.0", "postcss": "^8.4.49", "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^7.0.0", "semver": "^7.6.3", "svelte-eslint-parser": "^1.7.0" }, "peerDependencies": { "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-n9jRklDqy0+W834568a4ZIZTK7DHXxvvHHxxGP8nNq7N//pOZMubttskHOquyGgfTR4Z05q2NdGNlsTpIEA48w=="],
|
|
||||||
|
|
||||||
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
|
|
||||||
|
|
||||||
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
|
||||||
|
|
||||||
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
|
|
||||||
|
|
||||||
"espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
|
|
||||||
|
|
||||||
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
|
|
||||||
|
|
||||||
"esrap": ["esrap@2.2.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg=="],
|
|
||||||
|
|
||||||
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
|
||||||
|
|
||||||
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
|
||||||
|
|
||||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
|
||||||
|
|
||||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
|
||||||
|
|
||||||
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
|
||||||
|
|
||||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
|
||||||
|
|
||||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
|
||||||
|
|
||||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
|
||||||
|
|
||||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
|
||||||
|
|
||||||
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
|
||||||
|
|
||||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
|
||||||
|
|
||||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
|
||||||
|
|
||||||
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
|
||||||
|
|
||||||
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
|
||||||
|
|
||||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
|
||||||
|
|
||||||
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
|
|
||||||
|
|
||||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
|
||||||
|
|
||||||
"globals": ["globals@17.11.0", "", {}, "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw=="],
|
|
||||||
|
|
||||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
|
||||||
|
|
||||||
"happy-dom": ["happy-dom@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" } }, "sha512-QsCdAUHAmiDeKeaNojb1OHOPF7NjcWPBR7obdu3NwH2a/oyQaLg5d0aaCy/9My6CdPChYF07dvz5chaXBGaD4g=="],
|
|
||||||
|
|
||||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
|
||||||
|
|
||||||
"hls.js": ["hls.js@1.6.15", "", {}, "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA=="],
|
|
||||||
|
|
||||||
"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=="],
|
|
||||||
|
|
||||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
|
||||||
|
|
||||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
|
||||||
|
|
||||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
|
||||||
|
|
||||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
|
||||||
|
|
||||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
|
||||||
|
|
||||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
|
||||||
|
|
||||||
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
|
|
||||||
|
|
||||||
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
|
||||||
|
|
||||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
|
||||||
|
|
||||||
"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=="],
|
|
||||||
|
|
||||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
|
||||||
|
|
||||||
"js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
|
|
||||||
|
|
||||||
"jsdom": ["jsdom@27.4.0", "", { "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", "@exodus/bytes": "^1.6.0", "cssstyle": "^5.3.4", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.0", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.1.0", "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ=="],
|
|
||||||
|
|
||||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
|
||||||
|
|
||||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
|
||||||
|
|
||||||
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
|
||||||
|
|
||||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
|
||||||
|
|
||||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
|
||||||
|
|
||||||
"known-css-properties": ["known-css-properties@0.37.0", "", {}, "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="],
|
|
||||||
|
|
||||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
|
||||||
|
|
||||||
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
|
|
||||||
|
|
||||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
|
|
||||||
|
|
||||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
|
|
||||||
|
|
||||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
|
|
||||||
|
|
||||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
|
|
||||||
|
|
||||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
|
|
||||||
|
|
||||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
|
|
||||||
|
|
||||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
|
|
||||||
|
|
||||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
|
|
||||||
|
|
||||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
|
|
||||||
|
|
||||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
|
|
||||||
|
|
||||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
|
||||||
|
|
||||||
"lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
|
|
||||||
|
|
||||||
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
|
||||||
|
|
||||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
|
||||||
|
|
||||||
"lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
|
|
||||||
|
|
||||||
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
|
||||||
|
|
||||||
"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=="],
|
|
||||||
|
|
||||||
"minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="],
|
|
||||||
|
|
||||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
|
||||||
|
|
||||||
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
|
||||||
|
|
||||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
|
||||||
|
|
||||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
|
||||||
|
|
||||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
|
||||||
|
|
||||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
|
||||||
|
|
||||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
|
||||||
|
|
||||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
|
||||||
|
|
||||||
"parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="],
|
|
||||||
|
|
||||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
|
||||||
|
|
||||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
|
||||||
|
|
||||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
|
||||||
|
|
||||||
"postcss-load-config": ["postcss-load-config@3.1.4", "", { "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg=="],
|
|
||||||
|
|
||||||
"postcss-safe-parser": ["postcss-safe-parser@7.0.1", "", { "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A=="],
|
|
||||||
|
|
||||||
"postcss-scss": ["postcss-scss@4.0.9", "", { "peerDependencies": { "postcss": "^8.4.29" } }, "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A=="],
|
|
||||||
|
|
||||||
"postcss-selector-parser": ["postcss-selector-parser@7.1.5", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw=="],
|
|
||||||
|
|
||||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
|
||||||
|
|
||||||
"prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="],
|
|
||||||
|
|
||||||
"prettier-plugin-svelte": ["prettier-plugin-svelte@4.1.1", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^5.0.0" } }, "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA=="],
|
|
||||||
|
|
||||||
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
|
|
||||||
|
|
||||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
|
||||||
|
|
||||||
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
|
||||||
|
|
||||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
|
||||||
|
|
||||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
|
||||||
|
|
||||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
|
||||||
|
|
||||||
"rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="],
|
|
||||||
|
|
||||||
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
|
||||||
|
|
||||||
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
|
|
||||||
|
|
||||||
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
|
||||||
|
|
||||||
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
|
||||||
|
|
||||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
|
||||||
|
|
||||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
|
||||||
|
|
||||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
|
||||||
|
|
||||||
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
|
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
|
||||||
|
|
||||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
|
||||||
|
|
||||||
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
|
|
||||||
|
|
||||||
"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=="],
|
|
||||||
|
|
||||||
"svelte-check": ["svelte-check@4.3.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q=="],
|
|
||||||
|
|
||||||
"svelte-dnd-action": ["svelte-dnd-action@0.9.69", "", { "peerDependencies": { "svelte": ">=3.23.0 || ^5.0.0-next.0" } }, "sha512-NAmSOH7htJoYraTQvr+q5whlIuVoq88vEuHr4NcFgscDRUxfWPPxgie2OoxepBCQCikrXZV4pqV86aun60wVyw=="],
|
|
||||||
|
|
||||||
"svelte-eslint-parser": ["svelte-eslint-parser@1.8.1", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0", "semver": "^7.7.2" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-5zgKBqAf6V8Jyrmr1jViksyG4NKT8NheYwkdxRaMRDAqpGWi9wR8ktcGrAZbfMn/PHUp1BWzAp0cYQECkWuAMA=="],
|
|
||||||
|
|
||||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
|
||||||
|
|
||||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
|
||||||
|
|
||||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
|
||||||
|
|
||||||
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
|
||||||
|
|
||||||
"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=="],
|
|
||||||
|
|
||||||
"tldts-core": ["tldts-core@7.0.19", "", {}, "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A=="],
|
|
||||||
|
|
||||||
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
|
||||||
|
|
||||||
"tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="],
|
|
||||||
|
|
||||||
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
|
|
||||||
|
|
||||||
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
|
||||||
|
|
||||||
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
|
||||||
|
|
||||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
|
||||||
|
|
||||||
"typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
|
||||||
|
|
||||||
"typescript-eslint": ["typescript-eslint@8.67.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.67.0", "@typescript-eslint/parser": "8.67.0", "@typescript-eslint/typescript-estree": "8.67.0", "@typescript-eslint/utils": "8.67.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg=="],
|
|
||||||
|
|
||||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
|
||||||
|
|
||||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
|
||||||
|
|
||||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
|
||||||
|
|
||||||
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
|
||||||
|
|
||||||
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
|
|
||||||
|
|
||||||
"vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="],
|
|
||||||
|
|
||||||
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
|
|
||||||
|
|
||||||
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
|
|
||||||
|
|
||||||
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
|
|
||||||
|
|
||||||
"whatwg-url": ["whatwg-url@15.1.0", "", { "dependencies": { "tr46": "^6.0.0", "webidl-conversions": "^8.0.0" } }, "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g=="],
|
|
||||||
|
|
||||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
|
||||||
|
|
||||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
|
||||||
|
|
||||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
|
||||||
|
|
||||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
|
||||||
|
|
||||||
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
|
|
||||||
|
|
||||||
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
|
|
||||||
|
|
||||||
"yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
|
|
||||||
|
|
||||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
|
||||||
|
|
||||||
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
|
||||||
|
|
||||||
"@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=="],
|
|
||||||
|
|
||||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-ryJnSmj4UhrGLZZPJ6PKVb4wNPAIkW6iyLy+0TRwazd3L1u0wzMe8RfqevAh2HbcSkoeLiSYnOVDOys4JSGYyg=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Z82FDl1ByxqPEPrAYYeTQVlx2FSHPe1qwX465c+96IRS3fTdSYRoJcRxg3g2fEG5I69z1dSEWQlNRRr0/677mg=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
|
||||||
|
|
||||||
"@tauri-apps/plugin-os/@tauri-apps/api": ["@tauri-apps/api@2.9.1", "", {}, "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw=="],
|
|
||||||
|
|
||||||
"@tauri-apps/plugin-process/@tauri-apps/api": ["@tauri-apps/api@2.9.1", "", {}, "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw=="],
|
|
||||||
|
|
||||||
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
|
||||||
|
|
||||||
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="],
|
|
||||||
|
|
||||||
"@vitest/expect/@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="],
|
|
||||||
|
|
||||||
"@vitest/runner/@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="],
|
|
||||||
|
|
||||||
"@vitest/snapshot/@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="],
|
|
||||||
|
|
||||||
"@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=="],
|
|
||||||
|
|
||||||
"data-urls/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
|
||||||
|
|
||||||
"eslint-plugin-svelte/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
|
|
||||||
|
|
||||||
"espree/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
|
||||||
|
|
||||||
"jsdom/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
|
||||||
|
|
||||||
"svelte/devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="],
|
|
||||||
|
|
||||||
"svelte-eslint-parser/eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
|
||||||
|
|
||||||
"svelte-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
|
||||||
|
|
||||||
"svelte-eslint-parser/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
|
|
||||||
|
|
||||||
"tsx/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
|
|
||||||
|
|
||||||
"vitest/@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="],
|
|
||||||
|
|
||||||
"@vitest/ui/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.0.16", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA=="],
|
|
||||||
|
|
||||||
"svelte-eslint-parser/espree/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="],
|
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
|
||||||
# Test service - runs tests only
|
|
||||||
test:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: test
|
|
||||||
container_name: jellytau-test
|
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
environment:
|
|
||||||
- RUST_BACKTRACE=1
|
|
||||||
command: bash -c "bun run test && cd src-tauri && cargo test && cd .. && echo 'All tests passed!'"
|
|
||||||
|
|
||||||
# Android build service - builds APK after tests pass
|
|
||||||
android-build:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: android-build
|
|
||||||
container_name: jellytau-android-build
|
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
- android-cache:/root/.cargo
|
|
||||||
- android-bun-cache:/root/.bun
|
|
||||||
environment:
|
|
||||||
- RUST_BACKTRACE=1
|
|
||||||
- ANDROID_HOME=/opt/android-sdk
|
|
||||||
depends_on:
|
|
||||||
- test
|
|
||||||
ports:
|
|
||||||
- "5172:5172" # In case you want to run dev server
|
|
||||||
|
|
||||||
# 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:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: builder
|
|
||||||
container_name: jellytau-dev
|
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
- cargo-cache:/root/.cargo
|
|
||||||
- bun-cache:/root/.bun
|
|
||||||
- node-modules:/app/node_modules
|
|
||||||
environment:
|
|
||||||
- RUST_BACKTRACE=1
|
|
||||||
- ANDROID_HOME=/opt/android-sdk
|
|
||||||
- NDK_HOME=/opt/android-sdk/ndk/27.0.11902837
|
|
||||||
working_dir: /app
|
|
||||||
stdin_open: true
|
|
||||||
tty: true
|
|
||||||
command: /bin/bash
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
cargo-cache:
|
|
||||||
bun-cache:
|
|
||||||
android-cache:
|
|
||||||
android-bun-cache:
|
|
||||||
node-modules:
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# Summary
|
|
||||||
|
|
||||||
[Introduction](README.md)
|
|
||||||
|
|
||||||
# Requirements & Traceability
|
|
||||||
|
|
||||||
- [Requirements Specification](requirements.md)
|
|
||||||
- [Traceability Matrix](traceability.md)
|
|
||||||
- [Traceability CI](traceability-ci.md)
|
|
||||||
- [Traces Quick Reference](traces-quick-ref.md)
|
|
||||||
|
|
||||||
# Architecture
|
|
||||||
|
|
||||||
- [Overview](architecture/README.md)
|
|
||||||
- [Rust Backend](architecture/01-rust-backend.md)
|
|
||||||
- [Svelte Frontend](architecture/02-svelte-frontend.md)
|
|
||||||
- [Data Flow](architecture/03-data-flow.md)
|
|
||||||
- [Type Sync & Threading](architecture/04-type-sync-and-threading.md)
|
|
||||||
- [Platform Backends](architecture/05-platform-backends.md)
|
|
||||||
- [Downloads & Offline](architecture/06-downloads-and-offline.md)
|
|
||||||
- [Connectivity](architecture/07-connectivity.md)
|
|
||||||
- [Database Design](architecture/08-database-design.md)
|
|
||||||
- [Security](architecture/09-security.md)
|
|
||||||
|
|
||||||
# UX
|
|
||||||
|
|
||||||
- [UX Flows](ux-flows.md)
|
|
||||||
|
|
||||||
# Specs — Pending Work
|
|
||||||
|
|
||||||
- [Specs Index](specs/README.md)
|
|
||||||
- [Spec Template](specs/SPEC-TEMPLATE.md)
|
|
||||||
- [Spec Review Checklist](specs/SPEC-REVIEW-CHECKLIST.md)
|
|
||||||
- [Playback Backend Unification](specs/playback-backend-unification.md)
|
|
||||||
- [Linux Native Video Spike](specs/linux-native-video-spike.md)
|
|
||||||
- [Backend-Owned Stream Selection](specs/backend-owned-stream-selection.md)
|
|
||||||
- [Player Facade Enforcement](specs/player-facade-enforcement.md)
|
|
||||||
- [Windows Native Audio Backend](specs/windows-native-audio-backend.md)
|
|
||||||
- [libmpv2 Migration](specs/libmpv2-migration.md)
|
|
||||||
- [Read-Through Media Cache](specs/read-through-media-cache.md)
|
|
||||||
- [Scoped Search](specs/scoped-search.md)
|
|
||||||
- [Scoped Search Boundary](specs/scoped-search-boundary.md)
|
|
||||||
- [Scoped Search Boundary — Implementation](specs/scoped-search-boundary-implementation.md)
|
|
||||||
- [Frontend Domain Model](specs/frontend-domain-model.md)
|
|
||||||
- [Desktop Native Video](specs/desktop-native-video.md)
|
|
||||||
- [Build Provenance](specs/build-provenance.md)
|
|
||||||
|
|
||||||
# Build & Release
|
|
||||||
|
|
||||||
- [Build & Release](build/build-release.md)
|
|
||||||
- [Release Checklist](release-checklist.md)
|
|
||||||
- [Native Player Verification](native-player-verification.md)
|
|
||||||
- [Desktop Packaging](build/build-desktop-packages.md)
|
|
||||||
- [Windows Build](build/build-windows.md)
|
|
||||||
- [Defect Windows](defect-windows.md)
|
|
||||||
- [Docker](build/docker.md)
|
|
||||||
- [Builder Image](build/build-builder-image.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
[Rust API Reference (rustdoc)](api-redirect.md)
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# mdBook config for the published JellyTau documentation site.
|
|
||||||
# The book's `src` is the repo `docs/` directory (see [build] below); this file
|
|
||||||
# and SUMMARY.md live in docs-site/ to avoid cluttering docs/. The publish-docs
|
|
||||||
# CI job copies SUMMARY.md into docs/ at build time, renders, and pushes the
|
|
||||||
# result (plus the rustdoc API under /api/) to the orphan `gitea-pages` branch.
|
|
||||||
[book]
|
|
||||||
title = "JellyTau Documentation"
|
|
||||||
description = "Requirements, traceability, and architecture for the JellyTau Jellyfin client."
|
|
||||||
authors = ["Duncan Tourolle"]
|
|
||||||
language = "en"
|
|
||||||
# Sources live in the repo docs/ dir (one level up from this book root).
|
|
||||||
src = "../docs"
|
|
||||||
|
|
||||||
[output.html]
|
|
||||||
default-theme = "navy"
|
|
||||||
preferred-dark-theme = "navy"
|
|
||||||
git-repository-url = "https://gitea.tourolle.paris/dtourolle/jellytau"
|
|
||||||
edit-url-template = "https://gitea.tourolle.paris/dtourolle/jellytau/_edit/master/docs/{path}"
|
|
||||||
|
|
||||||
[output.html.fold]
|
|
||||||
enable = true
|
|
||||||
level = 1
|
|
||||||
|
|
||||||
[output.html.search]
|
|
||||||
enable = true
|
|
||||||
@@ -1,857 +0,0 @@
|
|||||||
# Rust Backend Architecture
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/`
|
|
||||||
|
|
||||||
## Media Session State Machine
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/session.rs`
|
|
||||||
|
|
||||||
The media session tracks the high-level playback context (what kind of media is being consumed) and persists beyond individual playback states. This enables persistent UI (miniplayer for audio) and proper transitions between content types.
|
|
||||||
|
|
||||||
**Architecture Note:** The session manager is a separate app-level state manager (not inside PlayerController), coordinated by the commands layer. This maintains clean separation of concerns.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
stateDiagram-v2
|
|
||||||
[*] --> Idle
|
|
||||||
|
|
||||||
Idle --> AudioActive : play_queue(audio)
|
|
||||||
Idle --> MovieActive : play_item(movie)
|
|
||||||
Idle --> TvShowActive : play_item(episode)
|
|
||||||
|
|
||||||
state "Audio Session" as AudioSession {
|
|
||||||
[*] --> AudioActive
|
|
||||||
AudioActive --> AudioInactive : playback_ended
|
|
||||||
AudioInactive --> AudioActive : resume/play
|
|
||||||
AudioActive --> AudioActive : next/previous
|
|
||||||
}
|
|
||||||
|
|
||||||
state "Movie Session" as MovieSession {
|
|
||||||
[*] --> MovieActive
|
|
||||||
MovieActive --> MovieInactive : playback_ended
|
|
||||||
MovieInactive --> MovieActive : resume
|
|
||||||
}
|
|
||||||
|
|
||||||
state "TV Show Session" as TvShowSession {
|
|
||||||
[*] --> TvShowActive
|
|
||||||
TvShowActive --> TvShowInactive : playback_ended
|
|
||||||
TvShowInactive --> TvShowActive : next_episode/resume
|
|
||||||
}
|
|
||||||
|
|
||||||
AudioSession --> Idle : dismiss/clear_queue
|
|
||||||
AudioSession --> MovieSession : play_item(movie)
|
|
||||||
AudioSession --> TvShowSession : play_item(episode)
|
|
||||||
|
|
||||||
MovieSession --> Idle : dismiss/playback_complete
|
|
||||||
MovieSession --> AudioSession : play_queue(audio)
|
|
||||||
|
|
||||||
TvShowSession --> Idle : dismiss/series_complete
|
|
||||||
TvShowSession --> AudioSession : play_queue(audio)
|
|
||||||
|
|
||||||
note right of Idle
|
|
||||||
No active media session
|
|
||||||
Queue may exist but not playing
|
|
||||||
No miniplayer/video player shown
|
|
||||||
end note
|
|
||||||
|
|
||||||
note right of AudioSession
|
|
||||||
SHOW: Miniplayer (always visible)
|
|
||||||
- Active: Play/pause/skip controls enabled
|
|
||||||
- Inactive: Play button to resume queue
|
|
||||||
Persists until explicit dismiss
|
|
||||||
end note
|
|
||||||
|
|
||||||
note right of MovieSession
|
|
||||||
SHOW: Full video player
|
|
||||||
- Active: Video playing/paused
|
|
||||||
- Inactive: Resume dialog
|
|
||||||
Auto-dismiss when playback ends
|
|
||||||
end note
|
|
||||||
|
|
||||||
note right of TvShowSession
|
|
||||||
SHOW: Full video player + Next Episode UI
|
|
||||||
- Active: Video playing/paused
|
|
||||||
- Inactive: Next episode prompt
|
|
||||||
Auto-dismiss when series ends
|
|
||||||
end note
|
|
||||||
```
|
|
||||||
|
|
||||||
**Session State Enum:**
|
|
||||||
```rust
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
|
||||||
pub enum MediaSessionType {
|
|
||||||
/// No active session - browsing library
|
|
||||||
Idle,
|
|
||||||
|
|
||||||
/// Audio playback session (music, audiobooks, podcasts)
|
|
||||||
/// Persists until explicitly dismissed
|
|
||||||
Audio {
|
|
||||||
/// Last/current track being played
|
|
||||||
last_item: Option<MediaItem>,
|
|
||||||
/// True = playing/paused, False = stopped/ended
|
|
||||||
is_active: bool,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Movie playback (single video, auto-dismiss on end)
|
|
||||||
Movie {
|
|
||||||
item: MediaItem,
|
|
||||||
is_active: bool, // true = playing/paused, false = ended
|
|
||||||
},
|
|
||||||
|
|
||||||
/// TV show playback (supports next episode auto-advance)
|
|
||||||
TvShow {
|
|
||||||
item: MediaItem,
|
|
||||||
series_id: String,
|
|
||||||
is_active: bool, // true = playing/paused, false = ended
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**State Transitions & Rules:**
|
|
||||||
|
|
||||||
| From State | Event | To State | UI Behavior | Notes |
|
|
||||||
|------------|-------|----------|-------------|-------|
|
|
||||||
| Idle | `play_queue(audio)` | Audio (active) | Show miniplayer | Creates audio session |
|
|
||||||
| Idle | `play_item(movie)` | Movie (active) | Show video player | Creates movie session |
|
|
||||||
| Idle | `play_item(episode)` | TvShow (active) | Show video player | Creates TV session |
|
|
||||||
| Audio (active) | `playback_ended` | Audio (inactive) | Miniplayer stays visible | Queue preserved |
|
|
||||||
| Audio (inactive) | `play/resume` | Audio (active) | Miniplayer enabled | Resume from queue |
|
|
||||||
| Audio (active/inactive) | `dismiss` | Idle | Hide miniplayer | Clear session |
|
|
||||||
| Audio (active/inactive) | `play_item(movie)` | Movie (active) | Switch to video player | Replace session |
|
|
||||||
| Movie (active) | `playback_ended` | Idle | Hide video player | Auto-dismiss |
|
|
||||||
| Movie (active) | `dismiss` | Idle | Hide video player | User dismiss |
|
|
||||||
| TvShow (active) | `playback_ended` | TvShow (inactive) | Show next episode UI | Wait for user choice |
|
|
||||||
| TvShow (inactive) | `next_episode` | TvShow (active) | Play next episode | Stay in session |
|
|
||||||
| TvShow (inactive) | `series_complete` | Idle | Hide video player | No more episodes |
|
|
||||||
|
|
||||||
**Key Design Decisions:**
|
|
||||||
|
|
||||||
1. **Audio Sessions Persist**: Miniplayer stays visible even when queue ends, allows easy resume
|
|
||||||
2. **Video Sessions Auto-Dismiss**: Movies auto-close when finished (unless paused)
|
|
||||||
3. **Single Active Session**: Playing new content type replaces current session
|
|
||||||
4. **Explicit Dismiss for Audio**: User must click close button to clear audio session
|
|
||||||
5. **Session != PlayerState**: Session is higher-level, PlayerState tracks playing/paused/seeking
|
|
||||||
|
|
||||||
**Edge Cases Handled:**
|
|
||||||
|
|
||||||
- Album finishes: Session goes inactive, miniplayer shows last track with play disabled
|
|
||||||
- User wants to dismiss: Close button clears session -> Idle
|
|
||||||
- Switch content types: New session replaces old (audio -> movie)
|
|
||||||
- Paused for extended time: Session persists indefinitely
|
|
||||||
- Playback errors: Session stays inactive, allows retry
|
|
||||||
- Queue operations while idle: Queue exists but no session created until play
|
|
||||||
|
|
||||||
## Player State Machine (Low-Level Playback)
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/state.rs`
|
|
||||||
|
|
||||||
The player uses a deterministic state machine with 6 states (operates within a media session):
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
stateDiagram-v2
|
|
||||||
[*] --> Idle
|
|
||||||
Idle --> Loading : Load
|
|
||||||
Loading --> Playing : MediaLoaded
|
|
||||||
Playing --> Paused : Pause
|
|
||||||
Paused --> Playing : Play
|
|
||||||
Paused --> Seeking : Seek
|
|
||||||
Seeking --> Playing : PositionUpdate
|
|
||||||
Playing --> Idle : Stop
|
|
||||||
Paused --> Idle : Stop
|
|
||||||
Idle --> Error : Error
|
|
||||||
Loading --> Error : Error
|
|
||||||
Playing --> Error : Error
|
|
||||||
Paused --> Error : Error
|
|
||||||
Seeking --> Error : Error
|
|
||||||
|
|
||||||
state Playing {
|
|
||||||
[*] : position, duration
|
|
||||||
}
|
|
||||||
state Paused {
|
|
||||||
[*] : position, duration
|
|
||||||
}
|
|
||||||
state Seeking {
|
|
||||||
[*] : target
|
|
||||||
}
|
|
||||||
state Error {
|
|
||||||
[*] : error message
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**State Enum:**
|
|
||||||
```rust
|
|
||||||
pub enum PlayerState {
|
|
||||||
Idle,
|
|
||||||
Loading { media: MediaItem },
|
|
||||||
Playing { media: MediaItem, position: f64, duration: f64 },
|
|
||||||
Paused { media: MediaItem, position: f64, duration: f64 },
|
|
||||||
Seeking { media: MediaItem, target: f64 },
|
|
||||||
Error { media: Option<MediaItem>, error: String },
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Event Enum:**
|
|
||||||
```rust
|
|
||||||
pub enum PlayerEvent {
|
|
||||||
Load(MediaItem),
|
|
||||||
Play,
|
|
||||||
Pause,
|
|
||||||
Stop,
|
|
||||||
Seek(f64),
|
|
||||||
Next,
|
|
||||||
Previous,
|
|
||||||
MediaLoaded(f64), // duration
|
|
||||||
PositionUpdate(f64), // position
|
|
||||||
PlaybackEnded,
|
|
||||||
Error(String),
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Playback Mode State Machine
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/playback_mode/mod.rs`
|
|
||||||
|
|
||||||
The playback mode manages whether media is playing locally on the device or remotely on another Jellyfin session (TV, browser, etc.):
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
stateDiagram-v2
|
|
||||||
[*] --> Idle
|
|
||||||
|
|
||||||
Idle --> Local : play_queue()
|
|
||||||
Idle --> Remote : transfer_to_remote(session_id)
|
|
||||||
|
|
||||||
Local --> Remote : transfer_to_remote(session_id)
|
|
||||||
Local --> Idle : stop()
|
|
||||||
|
|
||||||
Remote --> Local : transfer_to_local()
|
|
||||||
Remote --> Idle : session_disconnected()
|
|
||||||
Remote --> Idle : stop()
|
|
||||||
|
|
||||||
state Local {
|
|
||||||
[*] : Playing on device
|
|
||||||
[*] : ExoPlayer active
|
|
||||||
[*] : Volume buttons -> device
|
|
||||||
}
|
|
||||||
|
|
||||||
state Remote {
|
|
||||||
[*] : Controlling session
|
|
||||||
[*] : session_id
|
|
||||||
[*] : Volume buttons -> remote
|
|
||||||
[*] : Android: VolumeProvider active
|
|
||||||
}
|
|
||||||
|
|
||||||
state Idle {
|
|
||||||
[*] : No active playback
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**State Enum:**
|
|
||||||
```rust
|
|
||||||
pub enum PlaybackMode {
|
|
||||||
Local, // Playing on local device
|
|
||||||
Remote { session_id: String }, // Controlling remote Jellyfin session
|
|
||||||
Idle, // No active playback
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**State Transitions:**
|
|
||||||
|
|
||||||
| From | Event | To | Side Effects |
|
|
||||||
|------|-------|-----|----|
|
|
||||||
| Idle | `play_queue()` | Local | Start local playback |
|
|
||||||
| Idle | `transfer_to_remote(session_id)` | Remote | Send queue to remote session |
|
|
||||||
| Local | `transfer_to_remote(session_id)` | Remote | Stop local, send queue to remote, enable remote volume (Android) |
|
|
||||||
| Local | `stop()` | Idle | Stop local playback |
|
|
||||||
| Remote | `transfer_to_local()` | Local | Get remote state, stop remote, start local at same position, disable remote volume |
|
|
||||||
| Remote | `stop()` | Idle | Stop remote playback, disable remote volume |
|
|
||||||
| Remote | `session_disconnected()` | Idle | Session lost, disable remote volume |
|
|
||||||
|
|
||||||
**Integration with Player State Machine:**
|
|
||||||
|
|
||||||
- When `PlaybackMode = Local`: Player state machine is active (Idle/Loading/Playing/Paused/etc.)
|
|
||||||
- When `PlaybackMode = Remote`: Player state is typically Idle (remote session controls playback)
|
|
||||||
- When `PlaybackMode = Idle`: Player state is Idle
|
|
||||||
|
|
||||||
**Android Volume Control Integration:**
|
|
||||||
|
|
||||||
When transitioning to `Remote` mode on Android:
|
|
||||||
1. Call `enable_remote_volume(initial_volume)`
|
|
||||||
2. VolumeProviderCompat intercepts hardware volume buttons
|
|
||||||
3. PlaybackStateCompat is set to STATE_PLAYING (shows volume UI)
|
|
||||||
4. Volume commands routed to remote session via Jellyfin API
|
|
||||||
|
|
||||||
When transitioning away from `Remote` mode:
|
|
||||||
1. Call `disable_remote_volume()`
|
|
||||||
2. Volume buttons return to controlling device volume
|
|
||||||
3. PlaybackStateCompat set to STATE_NONE
|
|
||||||
4. VolumeProviderCompat is cleared
|
|
||||||
|
|
||||||
## Media Item & Source
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/media.rs`
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct MediaItem {
|
|
||||||
pub id: String,
|
|
||||||
pub title: String,
|
|
||||||
pub artist: Option<String>,
|
|
||||||
pub album: Option<String>,
|
|
||||||
pub duration: Option<f64>,
|
|
||||||
pub artwork_url: Option<String>,
|
|
||||||
pub media_type: MediaType,
|
|
||||||
pub source: MediaSource,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum MediaType {
|
|
||||||
Audio,
|
|
||||||
Video,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum MediaSource {
|
|
||||||
Remote {
|
|
||||||
stream_url: String,
|
|
||||||
jellyfin_item_id: String,
|
|
||||||
},
|
|
||||||
Local {
|
|
||||||
file_path: PathBuf,
|
|
||||||
jellyfin_item_id: Option<String>,
|
|
||||||
},
|
|
||||||
DirectUrl {
|
|
||||||
url: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `MediaSource` enum enables:
|
|
||||||
- **Remote**: Streaming from Jellyfin server
|
|
||||||
- **Local**: Downloaded/cached files (future offline support)
|
|
||||||
- **DirectUrl**: Direct URLs (channel plugins, external sources)
|
|
||||||
|
|
||||||
## Queue Manager
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/queue.rs`
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct QueueManager {
|
|
||||||
items: Vec<MediaItem>,
|
|
||||||
current_index: Option<usize>,
|
|
||||||
shuffle: bool,
|
|
||||||
repeat: RepeatMode,
|
|
||||||
shuffle_order: Vec<usize>, // Fisher-Yates permutation
|
|
||||||
history: Vec<usize>, // For back navigation in shuffle
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum RepeatMode {
|
|
||||||
Off,
|
|
||||||
All,
|
|
||||||
One,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Queue Navigation Logic:**
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
QM[QueueManager]
|
|
||||||
QM --> Shuffle
|
|
||||||
QM --> Repeat
|
|
||||||
QM --> History
|
|
||||||
|
|
||||||
subgraph Shuffle["Shuffle Mode"]
|
|
||||||
ShuffleOff["OFF<br/>next() returns index + 1"]
|
|
||||||
ShuffleOn["ON<br/>next() follows shuffle_order[]"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Repeat["Repeat Mode"]
|
|
||||||
RepeatOff["OFF<br/>next() at end: -> None"]
|
|
||||||
RepeatAll["ALL<br/>next() at end: -> wrap to index 0"]
|
|
||||||
RepeatOne["ONE<br/>next() returns same item"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph History["History"]
|
|
||||||
HistoryDesc["Used for previous()<br/>in shuffle mode"]
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
## Favorites System
|
|
||||||
|
|
||||||
**Location**:
|
|
||||||
- Commands: `src-tauri/src/commands/favorites.rs` (offline drain),
|
|
||||||
`src-tauri/src/commands/repository.rs` (query + toggle),
|
|
||||||
`src-tauri/src/commands/storage/` (local `user_data` writes)
|
|
||||||
- Repository: `get_favorites` on the trait, implemented by `online.rs`,
|
|
||||||
`offline.rs` and `hybrid.rs`
|
|
||||||
- Frontend: `src/lib/services/favorites.ts`,
|
|
||||||
`src/lib/components/FavoriteButton.svelte`, `/library/favorites`
|
|
||||||
|
|
||||||
Favouriting has two halves that are easy to confuse: **marking** an item, which
|
|
||||||
has existed since UR-017, and **browsing** what was marked, which arrived with
|
|
||||||
UR-067…069 (DR-113 … DR-120). Both go through the repository, not around it.
|
|
||||||
|
|
||||||
### Marking
|
|
||||||
|
|
||||||
Optimistic local write, then server sync:
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
UI[FavoriteButton] -->|Click| Service[toggleFavorite]
|
|
||||||
Service -->|"1. Optimistic"| LocalDB[("SQLite user_data<br/>is_favorite, pending_sync")]
|
|
||||||
Service -->|"2. Sync"| Repo[Repository]
|
|
||||||
Repo -->|POST / DELETE| JellyfinAPI["/Users/{id}/FavoriteItems/{itemId}"]
|
|
||||||
Service -->|"3. Mark synced"| LocalDB
|
|
||||||
Drain["spawn_favorites_drain<br/>(background task)"] -->|"pending_sync = 1"| Repo
|
|
||||||
```
|
|
||||||
|
|
||||||
1. The local row is updated immediately, so the heart fills without a round trip.
|
|
||||||
2. The repository is asked to mark or unmark on the server.
|
|
||||||
3. On success `pending_sync` is cleared; on failure the row stays pending.
|
|
||||||
4. A **background drain** (`spawn_favorites_drain`, started in `lib.rs` setup)
|
|
||||||
retries pending rows, so a favourite marked offline still reaches the server
|
|
||||||
(DR-120). This is the same pattern as the sync-queue drain — see
|
|
||||||
[Background workers](#background-workers).
|
|
||||||
|
|
||||||
### Browsing
|
|
||||||
|
|
||||||
`get_favorites(scope, options)` answers "what did this user favourite", across
|
|
||||||
libraries, with the **scope owned by Rust** — the frontend sends a
|
|
||||||
[`SearchScope`](#search-scope-and-the-taxonomy-boundary) variant and never names
|
|
||||||
an item type. `HybridRepository` splits it the same way it splits every query:
|
|
||||||
|
|
||||||
| Method | Used for |
|
|
||||||
|--------|----------|
|
|
||||||
| `get_favorites_cache_only` | The instant leg — the local `user_data` join |
|
|
||||||
| `get_favorites_server_only` | The reconciliation leg |
|
|
||||||
| `get_favorites` | Cache-first with server merge, per the repository's usual policy |
|
|
||||||
|
|
||||||
`GetItemsOptions.favorites_only` is the other entry point: it filters an
|
|
||||||
*existing* library listing rather than starting a cross-library query (DR-116),
|
|
||||||
which is what a library page's favourites filter uses.
|
|
||||||
|
|
||||||
Server favourite state is mirrored into the local `user_data` table on catalog
|
|
||||||
sync (DR-113/DR-114), so a favourite marked in another Jellyfin client shows up
|
|
||||||
here — before this, `MediaItem.user_data` was left empty and no query anywhere
|
|
||||||
asked for favourites.
|
|
||||||
|
|
||||||
**Tauri commands**:
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `repository_get_favorites` | Cross-library favourites for a scope |
|
|
||||||
| `repository_mark_favorite` / `repository_unmark_favorite` | Toggle on the server, through the repository |
|
|
||||||
| `storage_toggle_favorite` | Local optimistic write (`is_favorite`, `pending_sync`) |
|
|
||||||
| `storage_mark_synced` | Clear `pending_sync` after a successful server write |
|
|
||||||
|
|
||||||
**Frontend surfaces** (DR-117 … DR-119): the `/library/favorites` page with a
|
|
||||||
scope selector, favourite rows on home (`favoriteMovies` / `favoriteShows` /
|
|
||||||
`favoriteMusic` in `stores/home.ts`), a favourites tile per category in the
|
|
||||||
library mosaic, and `FavoriteButton` mounted wherever a whole item is shown —
|
|
||||||
movie, series, episode, album, artist and playlist detail views as well as the
|
|
||||||
mini player.
|
|
||||||
|
|
||||||
## Player Backend Trait
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/backend.rs`
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub trait PlayerBackend: Send + Sync {
|
|
||||||
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>;
|
|
||||||
fn play(&mut self) -> Result<(), PlayerError>;
|
|
||||||
fn pause(&mut self) -> Result<(), PlayerError>;
|
|
||||||
fn stop(&mut self) -> Result<(), PlayerError>;
|
|
||||||
fn seek(&mut self, position: f64) -> Result<(), PlayerError>;
|
|
||||||
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>;
|
|
||||||
fn position(&self) -> f64;
|
|
||||||
fn duration(&self) -> Option<f64>;
|
|
||||||
fn state(&self) -> PlayerState;
|
|
||||||
fn is_loaded(&self) -> bool;
|
|
||||||
fn volume(&self) -> f32;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Implementations:**
|
|
||||||
- `NullBackend` - Mock backend for testing
|
|
||||||
- `MpvBackend` - Linux playback via libmpv (see [05-platform-backends.md](05-platform-backends.md))
|
|
||||||
- `ExoPlayerBackend` - Android playback via ExoPlayer/Media3 (see [05-platform-backends.md](05-platform-backends.md))
|
|
||||||
|
|
||||||
## Player Controller
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/mod.rs`
|
|
||||||
|
|
||||||
The `PlayerController` orchestrates playback:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct PlayerController {
|
|
||||||
backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
|
|
||||||
queue: Arc<Mutex<QueueManager>>,
|
|
||||||
muted: bool,
|
|
||||||
sleep_timer: Arc<Mutex<SleepTimerState>>,
|
|
||||||
autoplay_settings: Arc<Mutex<AutoplaySettings>>,
|
|
||||||
autoplay_episode_count: Arc<Mutex<u32>>, // Session-based counter
|
|
||||||
repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
|
|
||||||
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
|
|
||||||
// ... other fields
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Methods:**
|
|
||||||
- `play_item(item)`: Load and play single item (resets autoplay counter)
|
|
||||||
- `play_queue(items, start_index)`: Load queue and start playback (resets autoplay counter)
|
|
||||||
- `next()` / `previous()`: Queue navigation (resets autoplay counter)
|
|
||||||
- `toggle_shuffle()` / `cycle_repeat()`: Mode changes
|
|
||||||
- `set_sleep_timer(mode)` / `cancel_sleep_timer()`: Sleep timer control
|
|
||||||
- `on_playback_ended()`: Autoplay decision making (checks sleep timer, episode limit, queue)
|
|
||||||
|
|
||||||
## Playlist System
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/commands/playlist.rs`, `src-tauri/src/repository/`
|
|
||||||
|
|
||||||
**TRACES**: UR-014 | JA-019 | JA-020
|
|
||||||
|
|
||||||
The playlist system provides full CRUD operations for Jellyfin playlists with offline support through the cache-first repository pattern.
|
|
||||||
|
|
||||||
**Types:**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// A media item within a playlist, with its distinct playlist entry ID
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct PlaylistEntry {
|
|
||||||
/// Jellyfin's PlaylistItemId (distinct from the media item ID)
|
|
||||||
pub playlist_item_id: String,
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub item: MediaItem,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Result of creating a new playlist
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct PlaylistCreatedResult {
|
|
||||||
pub id: String,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Design Decision**: `PlaylistEntry` wraps a `MediaItem` with a distinct `playlist_item_id`. This is critical because removing items from a playlist requires the playlist entry ID (not the media item ID), since the same track can appear multiple times.
|
|
||||||
|
|
||||||
**MediaRepository Trait Methods:**
|
|
||||||
```rust
|
|
||||||
async fn create_playlist(&self, name: &str, item_ids: Option<Vec<String>>) -> Result<PlaylistCreatedResult, RepoError>;
|
|
||||||
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError>;
|
|
||||||
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError>;
|
|
||||||
async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError>;
|
|
||||||
async fn add_to_playlist(&self, playlist_id: &str, item_ids: Vec<String>) -> Result<(), RepoError>;
|
|
||||||
async fn remove_from_playlist(&self, playlist_id: &str, entry_ids: Vec<String>) -> Result<(), RepoError>;
|
|
||||||
async fn move_playlist_item(&self, playlist_id: &str, item_id: &str, new_index: u32) -> Result<(), RepoError>;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Cache Strategy:**
|
|
||||||
- **Write operations** (create, delete, rename, add, remove, move): Delegate directly to online repository
|
|
||||||
- **Read operation** (`get_playlist_items`): Uses cache-first parallel racing (100ms cache timeout, server fallback)
|
|
||||||
- Background cache update after server fetch via `save_playlist_items_to_cache()`
|
|
||||||
|
|
||||||
**Playlist Tauri Commands:**
|
|
||||||
|
|
||||||
| Command | Parameters | Returns |
|
|
||||||
|---------|------------|---------|
|
|
||||||
| `playlist_create` | `handle, name, item_ids?` | `PlaylistCreatedResult` |
|
|
||||||
| `playlist_delete` | `handle, playlist_id` | `()` |
|
|
||||||
| `playlist_rename` | `handle, playlist_id, name` | `()` |
|
|
||||||
| `playlist_get_items` | `handle, playlist_id` | `Vec<PlaylistEntry>` |
|
|
||||||
| `playlist_add_items` | `handle, playlist_id, item_ids` | `()` |
|
|
||||||
| `playlist_remove_items` | `handle, playlist_id, entry_ids` | `()` |
|
|
||||||
| `playlist_move_item` | `handle, playlist_id, item_id, new_index` | `()` |
|
|
||||||
|
|
||||||
## Tauri Commands (Player)
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/commands/player.rs`
|
|
||||||
|
|
||||||
| Command | Parameters | Returns |
|
|
||||||
|---------|------------|---------|
|
|
||||||
| `player_play_item` | `PlayItemRequest` | `PlayerStatus` |
|
|
||||||
| `player_play_queue` | `items, start_index, shuffle` | `PlayerStatus` |
|
|
||||||
| `player_play` | - | `PlayerStatus` |
|
|
||||||
| `player_pause` | - | `PlayerStatus` |
|
|
||||||
| `player_toggle` | - | `PlayerStatus` |
|
|
||||||
| `player_stop` | - | `PlayerStatus` |
|
|
||||||
| `player_next` | - | `PlayerStatus` |
|
|
||||||
| `player_previous` | - | `PlayerStatus` |
|
|
||||||
| `player_seek` | `position: f64` | `PlayerStatus` |
|
|
||||||
| `player_set_volume` | `volume: f32` | `PlayerStatus` |
|
|
||||||
| `player_toggle_shuffle` | - | `QueueStatus` |
|
|
||||||
| `player_cycle_repeat` | - | `QueueStatus` |
|
|
||||||
| `player_get_status` | - | `PlayerStatus` |
|
|
||||||
| `player_get_queue` | - | `QueueStatus` |
|
|
||||||
| `player_get_session` | - | `MediaSessionType` |
|
|
||||||
| `player_dismiss_session` | - | `()` |
|
|
||||||
| `player_set_sleep_timer` | `mode: SleepTimerMode` | `()` |
|
|
||||||
| `player_cancel_sleep_timer` | - | `()` |
|
|
||||||
| `player_set_video_settings` | `settings: VideoSettings` | `VideoSettings` |
|
|
||||||
| `player_get_video_settings` | - | `VideoSettings` |
|
|
||||||
| `player_set_autoplay_settings` | `settings: AutoplaySettings` | `AutoplaySettings` |
|
|
||||||
| `player_get_autoplay_settings` | - | `AutoplaySettings` |
|
|
||||||
| `player_on_playback_ended` | - | `()` |
|
|
||||||
|
|
||||||
## Domain Vocabulary Owned by Rust
|
|
||||||
|
|
||||||
The frontend is presentation-only and must not encode Jellyfin's *taxonomy* — the
|
|
||||||
rule in [CLAUDE.md](../../CLAUDE.md) and
|
|
||||||
[scoped-search-boundary.md](../specs/scoped-search-boundary.md). These are the
|
|
||||||
places where that vocabulary actually lives.
|
|
||||||
|
|
||||||
### Search scope and the taxonomy boundary
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/repository/types.rs`
|
|
||||||
|
|
||||||
`SearchScope` is the canonical example the boundary rule is taught from. The
|
|
||||||
frontend sends an opaque variant; Rust expands it into Jellyfin item types:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub enum SearchScope { All, Music, Movies, Tv }
|
|
||||||
|
|
||||||
impl SearchScope {
|
|
||||||
/// The Jellyfin item types this scope requests, or `None` for `All`.
|
|
||||||
pub fn item_types(self) -> Option<Vec<String>> { … }
|
|
||||||
|
|
||||||
/// The scope a library of this Jellyfin `CollectionType` belongs to.
|
|
||||||
pub fn for_collection_type(collection_type: &str) -> Option<SearchScope> { … }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Two details that are load-bearing:
|
|
||||||
|
|
||||||
- `All` returns `None`, **not** the union of every listed type. An explicit
|
|
||||||
`includeItemTypes` list filters out anything not named in it, so a union would
|
|
||||||
silently drop People, folders, and any type nobody enumerated. Callers must
|
|
||||||
omit the filter entirely on `None`.
|
|
||||||
- `for_collection_type` maps a Jellyfin `CollectionType` to a favourites
|
|
||||||
category (DR-175). It changes when *Jellyfin* renames a collection type, not
|
|
||||||
when the library page is redesigned — which is the test for whether something
|
|
||||||
belongs on this side of the boundary.
|
|
||||||
|
|
||||||
⚠️ **The result side has not moved yet.** `GROUP_ITEM_TYPES` in
|
|
||||||
`src/lib/utils/searchScope.ts` still maps result groups to item types in the
|
|
||||||
frontend, and `check:boundary` does not match its shape. Tracked as Stage 2 of
|
|
||||||
[scoped-search-boundary-implementation.md](../specs/scoped-search-boundary-implementation.md).
|
|
||||||
|
|
||||||
### Library exclusions
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/repository/exclusions.rs` (TRACES: UR-076 | DR-209)
|
|
||||||
|
|
||||||
Folders the user has chosen to keep out of music browsing — a "Podcasts" folder
|
|
||||||
inside a music library being the canonical case. Excluded **by item id**, not by
|
|
||||||
name, in a process-wide `RwLock<Vec<String>>` restored from the database at
|
|
||||||
startup, and applied by the repository layer to every music query (libraries,
|
|
||||||
artists, albums, genres, search, home rows).
|
|
||||||
|
|
||||||
The id is normalised (`trim`, strip `-`, lowercase) because Jellyfin writes the
|
|
||||||
same GUID both dashed and undashed depending on the endpoint. The predecessor was
|
|
||||||
a frontend filter matching the English string "Podcasts" — wrong in three ways at
|
|
||||||
once, and the reason this lives in the repository.
|
|
||||||
|
|
||||||
The set is process-wide rather than a field on a repository for the same reason
|
|
||||||
as `online::STREAMING_QUALITY`: it is a preference about *this user's browsing*,
|
|
||||||
not about a server session, so it must survive a repository being rebuilt on
|
|
||||||
re-login.
|
|
||||||
|
|
||||||
### Streaming quality ladder
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/settings.rs` (TRACES: UR-074 | DR-162)
|
|
||||||
|
|
||||||
`StreamingQuality` is a bandwidth ladder (`Original`, 20/10/8/4/2/1 Mbps,
|
|
||||||
720 kbps), not a resolution picker: it exists to fit a connection, and the
|
|
||||||
resolution cap is chosen *from* the bitrate so the encoder does not spend a small
|
|
||||||
budget on pixels it cannot afford.
|
|
||||||
|
|
||||||
| Method | Answers |
|
|
||||||
|--------|---------|
|
|
||||||
| `max_bitrate()` | Total bits/s (video + audio), `None` for `Original` |
|
|
||||||
| `audio_bitrate()` | The audio share — shrinks down the ladder, so 384 kbps is not a third of the budget at the bottom |
|
|
||||||
| `video_bitrate()` | Total minus audio, so the two together honour the ceiling |
|
|
||||||
| `max_height()` | Resolution ceiling that suits the bitrate |
|
|
||||||
|
|
||||||
The ceiling goes to `PlaybackInfo` as `MaxStreamingBitrate` **and** into the
|
|
||||||
device profile. Sending it there — not just on the transcode URL — is what makes
|
|
||||||
the cap real: a stream the server decides to *direct play* is served at the
|
|
||||||
source file's own bitrate, and no URL parameter afterwards can reduce it.
|
|
||||||
|
|
||||||
#### Two levels of ceiling
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/repository/online.rs` (TRACES: UR-074, UR-079 | DR-226)
|
|
||||||
|
|
||||||
There are two, and they are not the same thing:
|
|
||||||
|
|
||||||
| | Set by | Lives until | Read via |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **Device default** | Settings (`player_set_video_settings`) | Persisted; restored at startup | `streaming_quality()` |
|
|
||||||
| **Per-playback override** | The in-player picker (`player_set_stream_quality`) | The next item starts playing | `playback_quality_override()` |
|
|
||||||
|
|
||||||
`effective_streaming_quality()` resolves the pair — override first, else default —
|
|
||||||
and **is the only thing stream construction may read**. Every URL builder and the
|
|
||||||
`PlaybackInfo` negotiation go through it, for the reason the process-wide static
|
|
||||||
existed in the first place: if the negotiation and the URL builder disagree, the
|
|
||||||
cap leaks — the negotiation authorises a direct play the builder then never gets
|
|
||||||
to constrain, or the reverse.
|
|
||||||
|
|
||||||
> The override exists because a single global cannot express "this 4K remux needs
|
|
||||||
> a ceiling, that podcast does not". The picker had documented itself as a "this
|
|
||||||
> film, this connection" control since it was written, but was implemented by
|
|
||||||
> writing the *default* — so dropping one awkward film to 2 Mbps silently capped
|
|
||||||
> every video played afterwards for the rest of the process, with Settings still
|
|
||||||
> showing the old value. It is cleared on every `player_play_item` /
|
|
||||||
> `player_play_queue` / `player_play_tracks`, which is what stops it surviving
|
|
||||||
> into an autoplayed next episode where nobody would reopen the picker.
|
|
||||||
|
|
||||||
### Stream selection
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/repository/stream_selection.rs`,
|
|
||||||
`OnlineRepository::get_stream_selection` (TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228)
|
|
||||||
|
|
||||||
**Rust decides *what stream*. The player decides *how to deliver it*.** That line
|
|
||||||
is the whole design. A backend with genuine adaptive selection (ExoPlayer over a
|
|
||||||
multi-variant playlist) is left to do it; Rust chooses what to request and never
|
|
||||||
paces bytes.
|
|
||||||
|
|
||||||
`get_stream_selection` returns one self-describing `StreamSelection` in place of
|
|
||||||
the bare URL `get_video_stream_url` used to hand out:
|
|
||||||
|
|
||||||
| Field | Carries |
|
|
||||||
|---|---|
|
|
||||||
| `url` | What to open |
|
|
||||||
| `transport` | `Hls` / `Progressive` / `LocalFile` — how to fetch it |
|
|
||||||
| `playback_kind` | `DirectPlay` / `DirectStream` / `Transcode` — what the server is doing to the source |
|
|
||||||
| `rendition` | The negotiated ceiling and codecs; `None` for a direct play, which *is* the source |
|
|
||||||
| `available` | The quality ladder as it applies to this media source (DR-227) |
|
|
||||||
| `needs_transcoding` | Derived from `playback_kind`, so the rule is answered once |
|
|
||||||
|
|
||||||
Both enums are serde-tagged (`{"type":"hls"}`) so the frontend matches a
|
|
||||||
discriminant rather than comparing text.
|
|
||||||
|
|
||||||
> **Why `transport` exists.** `VideoPlayer.svelte` chose its loader with
|
|
||||||
> `url.includes(".m3u8")`, in two places. Rust *built* that URL and knows exactly
|
|
||||||
> what it is; re-deriving it downstream by substring match is a domain fact
|
|
||||||
> reconstructed in the presentation layer — the same class of error as leaking
|
|
||||||
> item-type taxonomy, and one that fails silently in **both** directions: a
|
|
||||||
> progressive file served from a path containing the substring gets an HLS
|
|
||||||
> loader, and a playlist served from a path without it does not.
|
|
||||||
>
|
|
||||||
> The paths that never negotiate get the same shape from Rust rather than letting
|
|
||||||
> a caller assemble one — `media_local_selection` for a downloaded file,
|
|
||||||
> `LiveStreamInfo.transport` for a live channel — so there is no second place
|
|
||||||
> where a transport is decided.
|
|
||||||
|
|
||||||
#### The playback-kind decision
|
|
||||||
|
|
||||||
`decide_playback_kind` is a free function and pure, so every branch is testable
|
|
||||||
from `PlaybackInfo` fixtures without a server. Order matters — the two
|
|
||||||
client-side overrides come first, because each describes a case where the
|
|
||||||
server's answer is right about the *file* and wrong about what this app will do
|
|
||||||
with it:
|
|
||||||
|
|
||||||
1. **Undecodable audio → `Transcode`.** Jellyfin 10.11.5 honours a
|
|
||||||
DirectPlayProfile's container and video codec but *ignores its audio codec*,
|
|
||||||
so it offers direct play for an E-AC-3 track the webview renders in silence.
|
|
||||||
A silent direct play is worse than a transcode.
|
|
||||||
2. **A pinned audio track → `Transcode`.** Not a defect in the server's answer, a
|
|
||||||
different question: the file has one default track and the viewer asked for
|
|
||||||
another.
|
|
||||||
3. Otherwise `supports_direct_play` → `DirectPlay`, else `supports_direct_stream`
|
|
||||||
→ `DirectStream`, else `Transcode`.
|
|
||||||
|
|
||||||
A direct **stream** is a remux — codecs copied, container repackaged. It is cheap
|
|
||||||
and is deliberately *not* counted as transcoding; conflating the two would report
|
|
||||||
a free passthrough as a server-side re-encode.
|
|
||||||
|
|
||||||
> **What this is worth, measured.** Against the development server (Jellyfin
|
|
||||||
> 10.11.5), 400 items sampled for codec mix and 40 put through a real negotiation
|
|
||||||
> per profile:
|
|
||||||
>
|
|
||||||
> | Profile | Direct play |
|
|
||||||
> |---|---|
|
|
||||||
> | Linux / WebKitGTK (`h264` only, 2ch) | 3/40 — **7%** |
|
|
||||||
> | Android / ExoPlayer (`h264,hevc,vp8,vp9,av1,mpeg4` + `ac3,eac3`, 6ch) | 34/40 — **85%** |
|
|
||||||
>
|
|
||||||
> The library is ~80% hevc (`hevc+eac3` alone is a third of it), which is why the
|
|
||||||
> two diverge so hard.
|
|
||||||
>
|
|
||||||
> **Read that 85% as a ceiling, not a result.** It was measured with a profile
|
|
||||||
> containing `ac3,eac3`. The Android device this was later run on reports neither
|
|
||||||
> in its `MediaCodecList` — no Dolby licence, which is normal for a tablet — so
|
|
||||||
> eac3 content, about a third of the sampled library, correctly transcodes there.
|
|
||||||
> What any given device achieves depends on its own codec list, and on the
|
|
||||||
> profile being derived from the renderer at all (DR-234), which it was not when
|
|
||||||
> the figure was taken.
|
|
||||||
>
|
|
||||||
> **The payoff is still overwhelmingly Android**, because that is where a real
|
|
||||||
> decoder is already doing the work. Linux stays near 7% until libmpv decodes the
|
|
||||||
> picture — the h264-only profile is a WebKitGTK constraint, not a JellyTau
|
|
||||||
> choice, and is what `linux-native-video-spike.md` exists to remove. A reviewer
|
|
||||||
> should not expect this code to fix Linux on its own.
|
|
||||||
|
|
||||||
#### The quality ladder per source
|
|
||||||
|
|
||||||
`quality_options_for_source(source_bitrate)` returns every rung, each marked with
|
|
||||||
`exceeds_source`: true when that rung's ceiling is at or above what the source
|
|
||||||
itself carries, so selecting it produces the same bytes as `Original`. The
|
|
||||||
frontend draws the list and drops the redundant rungs; it does not decide which
|
|
||||||
they are.
|
|
||||||
|
|
||||||
- `Original` is never marked — it *is* the source.
|
|
||||||
- An unreported source bitrate (some containers have none; the sampled library
|
|
||||||
has `avi` files with no bitrate at all) marks **nothing** redundant, keeping
|
|
||||||
every rung offered. That is the safe direction: the viewer keeps every choice.
|
|
||||||
|
|
||||||
#### No adaptive ladder to preserve
|
|
||||||
|
|
||||||
**TRACES: UR-079 | DR-229 (Won't Do)**
|
|
||||||
|
|
||||||
Mid-playback re-negotiation on throughput was scoped and dropped on measurement.
|
|
||||||
A master playlist from this server carries exactly **one** `EXT-X-STREAM-INF`:
|
|
||||||
Jellyfin builds it from the single rendition the request asked for rather than
|
|
||||||
publishing a ladder. So there is no adaptation for hls.js to be preserving and
|
|
||||||
none that mpv would lose — the claim that there was is recorded in
|
|
||||||
`playback-backend-unification.md` and does not hold. "Adapt mid-stream" collapses
|
|
||||||
into "pick well at open", which is what the two levels of ceiling and the
|
|
||||||
per-source ladder already are.
|
|
||||||
|
|
||||||
Kept here because it is a measurement, not an opinion: a server that *does*
|
|
||||||
publish a ladder would change the answer, and the re-negotiation path below is
|
|
||||||
the hook that work would build on.
|
|
||||||
|
|
||||||
#### Re-negotiation
|
|
||||||
|
|
||||||
One mechanism, not two. `player_seek_video`, `player_switch_audio_track` and
|
|
||||||
`player_set_stream_quality` all return a tagged `strategy` saying who reloads —
|
|
||||||
the backend handles a native backend itself and hands the webview a
|
|
||||||
`StreamSelection` for `reloadSource`. Note the wire wart: tauri-specta keeps
|
|
||||||
these response fields snake_case (`seek_offset`), while the `strategy` tag itself
|
|
||||||
is camelCase.
|
|
||||||
|
|
||||||
The frontend names a variant and nothing else; the labels the picker shows are
|
|
||||||
served over IPC — from `available` on the selection, or
|
|
||||||
`player_get_streaming_qualities` for the Settings list.
|
|
||||||
|
|
||||||
## Background workers
|
|
||||||
|
|
||||||
Three long-lived tasks are spawned from the Tauri `setup` hook in `lib.rs`. All
|
|
||||||
three exist because *when* something happens is a backend policy, not something
|
|
||||||
a page load should decide.
|
|
||||||
|
|
||||||
| Worker | Location | Responsibility |
|
|
||||||
|--------|----------|----------------|
|
|
||||||
| `spawn_catalog_indexer` | `commands/catalog.rs` | Keeps the local FTS5 catalog fresh (DR-109, IR-030) |
|
|
||||||
| `spawn_favorites_drain` | `commands/favorites.rs` | Retries favourite toggles made while offline (DR-120) |
|
|
||||||
| `spawn_sync_queue_drain` | `commands/sync_drain.rs` | Drains the offline mutation queue (DR-131) |
|
|
||||||
|
|
||||||
### Catalog indexer
|
|
||||||
|
|
||||||
Replaces the frontend's startup-only `syncCatalog()` call. It ticks on
|
|
||||||
`CATALOG_INDEX_TICK` and runs a pass when three things hold: a repository exists,
|
|
||||||
the server is reachable, and the index is due per `index_is_due`. A tick is
|
|
||||||
nearly free — one indexed `app_settings` lookup — which is what makes it
|
|
||||||
responsive to events it cannot subscribe to, such as signing in: a fresh install
|
|
||||||
would otherwise sit unindexed until the next scheduled pass.
|
|
||||||
|
|
||||||
`index_is_due` treats both "never indexed" and an unparseable stored timestamp as
|
|
||||||
due; a corrupt timestamp should trigger a re-index, not silently freeze the
|
|
||||||
catalog. A failed pass is never fatal — it leaves the existing index in place and
|
|
||||||
warns. Progress is emitted on `CATALOG_INDEX_EVENT` for the staleness hint in the
|
|
||||||
UI.
|
|
||||||
@@ -1,883 +0,0 @@
|
|||||||
# Svelte Frontend Architecture
|
|
||||||
|
|
||||||
## Store Structure
|
|
||||||
|
|
||||||
**Location**: `src/lib/stores/`
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
subgraph Stores
|
|
||||||
subgraph auth["auth.ts"]
|
|
||||||
AuthState["AuthState<br/>- user<br/>- serverUrl<br/>- token<br/>- isLoading"]
|
|
||||||
end
|
|
||||||
subgraph playerStore["player.ts"]
|
|
||||||
PlayerStoreState["PlayerState<br/>- kind<br/>- media<br/>- position<br/>- duration"]
|
|
||||||
end
|
|
||||||
subgraph queueStore["queue.ts"]
|
|
||||||
QueueState["QueueState<br/>- items<br/>- index<br/>- shuffle<br/>- repeat"]
|
|
||||||
end
|
|
||||||
subgraph libraryStore["library.ts"]
|
|
||||||
LibraryState["LibraryState<br/>- libraries<br/>- items<br/>- loading"]
|
|
||||||
end
|
|
||||||
subgraph Derived["Derived Stores"]
|
|
||||||
DerivedList["isAuthenticated, currentUser<br/>isPlaying, isPaused, currentMedia<br/>hasNext, hasPrevious, isShuffle<br/>libraryItems, isLibraryLoading"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
## Music Library Architecture
|
|
||||||
|
|
||||||
**Category-Based Navigation:**
|
|
||||||
|
|
||||||
JellyTau's music library uses a category-based navigation system with a dedicated landing page that routes users to specialized views for different content types.
|
|
||||||
|
|
||||||
**Route Structure:**
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
Music["/library/music<br/>(Landing page with category cards)"]
|
|
||||||
Tracks["Tracks<br/>(List view only)"]
|
|
||||||
Artists["Artists<br/>(Grid view)"]
|
|
||||||
Albums["Albums<br/>(Grid view)"]
|
|
||||||
Playlists["Playlists<br/>(Grid view)"]
|
|
||||||
Genres["Genres<br/>(Genre browser)"]
|
|
||||||
|
|
||||||
Music --> Tracks
|
|
||||||
Music --> Artists
|
|
||||||
Music --> Albums
|
|
||||||
Music --> Playlists
|
|
||||||
Music --> Genres
|
|
||||||
```
|
|
||||||
|
|
||||||
**View Enforcement:**
|
|
||||||
|
|
||||||
Ordinal content (where position carries meaning) is always a list. Everything
|
|
||||||
else honours the user's persisted grid/list preference — see
|
|
||||||
[ux-flows.md §5A.2](../ux-flows.md).
|
|
||||||
|
|
||||||
| Content Type | View Mode | Toggle Visible | Component Used |
|
|
||||||
|--------------|-----------|----------------|----------------|
|
|
||||||
| Tracks | List (forced — ordinal) | No | `TrackList` |
|
|
||||||
| Artists | User preference | Yes | `LibraryGrid` |
|
|
||||||
| Albums | User preference | Yes | `LibraryGrid` |
|
|
||||||
| Playlists | User preference | Yes | `LibraryGrid` |
|
|
||||||
| Genres | User preference (both levels) | Yes | `LibraryGrid` |
|
|
||||||
| Album Detail Tracks | List (forced — ordinal) | No | `TrackList` |
|
|
||||||
| Season Episodes | List (forced — ordinal) | No | `SeasonSection` |
|
|
||||||
|
|
||||||
**TrackList Component:**
|
|
||||||
|
|
||||||
The `TrackList` component (`src/lib/components/library/TrackList.svelte`) is a dedicated component for displaying songs in list format:
|
|
||||||
|
|
||||||
- **No Thumbnails**: Track numbers only (transform to play button on hover)
|
|
||||||
- **Desktop Layout**: Table with columns: #, Title, Artist, Album, Duration
|
|
||||||
- **Mobile Layout**: Compact rows with track number and metadata
|
|
||||||
- **Configurable Columns**: `showArtist` and `showAlbum` props control column visibility
|
|
||||||
- **Click Behavior**: Clicking a track plays it and queues all filtered tracks
|
|
||||||
|
|
||||||
**Example Usage:**
|
|
||||||
```svelte
|
|
||||||
<TrackList
|
|
||||||
tracks={filteredTracks}
|
|
||||||
loading={loading}
|
|
||||||
showArtist={true}
|
|
||||||
showAlbum={true}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
**LibraryGrid view mode:**
|
|
||||||
|
|
||||||
`LibraryGrid` reads the global `viewMode` store (persisted to `localStorage`)
|
|
||||||
and renders `LibraryListView` or the card grid accordingly. The `showViewToggle`
|
|
||||||
prop controls whether the toggle buttons appear in the page header; the grid
|
|
||||||
itself always follows the stored preference.
|
|
||||||
|
|
||||||
A `forceGrid` prop previously existed to pin pages to grid regardless of
|
|
||||||
preference. No caller ever passed it, so it was removed — pages that were
|
|
||||||
documented as "forced grid" have in practice always honoured the toggle.
|
|
||||||
|
|
||||||
## Playback Reporting Service
|
|
||||||
|
|
||||||
**Location**: `src/lib/services/playbackReporting.ts`
|
|
||||||
|
|
||||||
The playback reporting service ensures playback progress is synced to both the Jellyfin server AND the local SQLite database. This dual-write approach enables:
|
|
||||||
- Offline "Continue Watching" functionality
|
|
||||||
- Sync queue for when network is unavailable
|
|
||||||
- Consistent progress across app restarts
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant VideoPlayer
|
|
||||||
participant PlaybackService as playbackReporting.ts
|
|
||||||
participant LocalDB as Local SQLite<br/>(Tauri Commands)
|
|
||||||
participant Jellyfin as Jellyfin Server
|
|
||||||
|
|
||||||
VideoPlayer->>PlaybackService: reportPlaybackProgress(itemId, position)
|
|
||||||
|
|
||||||
par Local Storage (always works)
|
|
||||||
PlaybackService->>LocalDB: invoke("storage_update_playback_progress")
|
|
||||||
LocalDB-->>PlaybackService: Ok (pending_sync = true)
|
|
||||||
and Server Sync (if online)
|
|
||||||
PlaybackService->>Jellyfin: POST /Sessions/Playing/Progress
|
|
||||||
Jellyfin-->>PlaybackService: Ok
|
|
||||||
PlaybackService->>LocalDB: invoke("storage_mark_synced")
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
**Service Functions:**
|
|
||||||
- `reportPlaybackStart(itemId, positionSeconds)` - Called when playback begins
|
|
||||||
- `reportPlaybackProgress(itemId, positionSeconds, isPaused)` - Called periodically (every 10s)
|
|
||||||
- `reportPlaybackStopped(itemId, positionSeconds)` - Called when player closes or video ends
|
|
||||||
|
|
||||||
**Tauri Commands:**
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `storage_update_playback_progress` | Update position in local DB (marks `pending_sync = true`) |
|
|
||||||
| `storage_mark_played` | Mark item as played, increment play count |
|
|
||||||
| `storage_get_playback_progress` | Get stored progress for an item |
|
|
||||||
| `storage_mark_synced` | Clear `pending_sync` flag after successful server sync |
|
|
||||||
|
|
||||||
**Database Schema Notes:**
|
|
||||||
- The `user_data` table stores playback progress using Jellyfin IDs directly (as TEXT)
|
|
||||||
- Playback progress can be tracked even when the full item metadata hasn't been downloaded yet
|
|
||||||
|
|
||||||
**Resume Playback Feature:**
|
|
||||||
- When loading media for playback, the app checks local database for saved progress
|
|
||||||
- If progress exists (>30 seconds watched and <90% complete), shows resume dialog
|
|
||||||
- User can choose to "Resume" from saved position or "Start from Beginning"
|
|
||||||
- For video: Uses `startTimeSeconds` parameter in stream URL to begin transcoding from resume point
|
|
||||||
- For audio: Seeks to resume position after loading via MPV backend
|
|
||||||
- Implemented in `src/routes/player/[id]/+page.svelte`
|
|
||||||
|
|
||||||
## Repository Architecture (Rust-Based)
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/repository/`
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
classDiagram
|
|
||||||
class MediaRepository {
|
|
||||||
<<trait>>
|
|
||||||
+get_libraries()
|
|
||||||
+get_items(parent_id, options)
|
|
||||||
+get_item(item_id)
|
|
||||||
+search(query, options)
|
|
||||||
+get_latest_items(parent_id, limit)
|
|
||||||
+get_resume_items(parent_id, limit)
|
|
||||||
+get_next_up_episodes(series_id, limit)
|
|
||||||
+get_genres(parent_id)
|
|
||||||
+get_playback_info(item_id)
|
|
||||||
+report_playback_start(item_id, position_ticks)
|
|
||||||
+report_playback_progress(item_id, position_ticks, is_paused)
|
|
||||||
+report_playback_stopped(item_id, position_ticks)
|
|
||||||
+mark_favorite(item_id)
|
|
||||||
+unmark_favorite(item_id)
|
|
||||||
+get_person(person_id)
|
|
||||||
+get_items_by_person(person_id, options)
|
|
||||||
+get_image_url(item_id, image_type, options)
|
|
||||||
+create_playlist(name, item_ids)
|
|
||||||
+delete_playlist(playlist_id)
|
|
||||||
+rename_playlist(playlist_id, name)
|
|
||||||
+get_playlist_items(playlist_id)
|
|
||||||
+add_to_playlist(playlist_id, item_ids)
|
|
||||||
+remove_from_playlist(playlist_id, entry_ids)
|
|
||||||
+move_playlist_item(playlist_id, item_id, new_index)
|
|
||||||
}
|
|
||||||
|
|
||||||
class OnlineRepository {
|
|
||||||
-http_client: Arc~HttpClient~
|
|
||||||
-server_url: String
|
|
||||||
-user_id: String
|
|
||||||
-access_token: String
|
|
||||||
-connectivity: Option~Arc~ConnectivityMonitor~~
|
|
||||||
+new()
|
|
||||||
+with_connectivity()
|
|
||||||
-report_outcome()
|
|
||||||
}
|
|
||||||
|
|
||||||
class OfflineRepository {
|
|
||||||
-db_service: Arc~DatabaseService~
|
|
||||||
-server_id: String
|
|
||||||
-user_id: String
|
|
||||||
+new()
|
|
||||||
+cache_library()
|
|
||||||
+cache_items()
|
|
||||||
+cache_item()
|
|
||||||
}
|
|
||||||
|
|
||||||
class HybridRepository {
|
|
||||||
-online: Arc~OnlineRepository~
|
|
||||||
-offline: Arc~OfflineRepository~
|
|
||||||
+new()
|
|
||||||
-parallel_race()
|
|
||||||
-cache_with_timeout()
|
|
||||||
}
|
|
||||||
|
|
||||||
MediaRepository <|.. OnlineRepository
|
|
||||||
MediaRepository <|.. OfflineRepository
|
|
||||||
MediaRepository <|.. HybridRepository
|
|
||||||
|
|
||||||
HybridRepository --> OnlineRepository
|
|
||||||
HybridRepository --> OfflineRepository
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Implementation Details:**
|
|
||||||
|
|
||||||
1. **Cache-First Racing Strategy** (`hybrid.rs`):
|
|
||||||
- Runs cache (SQLite) and server (HTTP) queries in parallel
|
|
||||||
- Cache has 100ms timeout
|
|
||||||
- Returns cache result if it has meaningful content
|
|
||||||
- Falls back to server result otherwise
|
|
||||||
- Background cache updates planned
|
|
||||||
- **Connectivity feedback**: `OnlineRepository` reports the outcome of every server request to the `ConnectivityMonitor` (classified via `RepoError`). This is the source of truth for the offline/online banner — see [07-connectivity.md](07-connectivity.md). The frontend `connectivity` store is a pure reflection of the resulting events; `navigator.onLine` is only an advisory hint that triggers an immediate recheck.
|
|
||||||
|
|
||||||
2. **Handle-Based Resource Management** (`repository.rs` commands):
|
|
||||||
```rust
|
|
||||||
// Frontend creates repository with UUID handle
|
|
||||||
repository_create(server_url, user_id, access_token, server_id) -> String (UUID)
|
|
||||||
|
|
||||||
// All operations use handle for identification
|
|
||||||
repository_get_libraries(handle: String) -> Vec<Library>
|
|
||||||
repository_get_items(handle: String, ...) -> SearchResult
|
|
||||||
|
|
||||||
// Cleanup when done
|
|
||||||
repository_destroy(handle: String)
|
|
||||||
```
|
|
||||||
- Enables multiple concurrent repository instances
|
|
||||||
- Thread-safe with `Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>`
|
|
||||||
- No global state conflicts
|
|
||||||
|
|
||||||
3. **Frontend API Layer** (`src/lib/api/repository-client.ts`):
|
|
||||||
- Thin TypeScript wrapper over Rust commands
|
|
||||||
- Maintains handle throughout session
|
|
||||||
- All methods: `invoke<T>("repository_operation", { handle, ...args })`
|
|
||||||
- ~100 lines (down from 1061 lines)
|
|
||||||
|
|
||||||
## Playback Mode System
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/playback_mode/mod.rs`
|
|
||||||
|
|
||||||
The playback mode system manages transitions between local device playback and remote Jellyfin session control:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub enum PlaybackMode {
|
|
||||||
Local, // Playing on local device
|
|
||||||
Remote { session_id: String }, // Controlling remote session
|
|
||||||
Idle, // Not playing
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PlaybackModeManager {
|
|
||||||
current_mode: PlaybackMode,
|
|
||||||
player_controller: Arc<Mutex<PlayerController>>,
|
|
||||||
jellyfin_client: Arc<JellyfinClient>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Operations:**
|
|
||||||
|
|
||||||
1. **Transfer to Remote** (`transfer_to_remote(session_id)`):
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant UI
|
|
||||||
participant Manager as PlaybackModeManager
|
|
||||||
participant Player as PlayerController
|
|
||||||
participant Jellyfin as Jellyfin API
|
|
||||||
|
|
||||||
UI->>Manager: transfer_to_remote(session_id)
|
|
||||||
Manager->>Player: Extract queue items
|
|
||||||
Manager->>Manager: Get Jellyfin IDs from queue
|
|
||||||
Manager->>Jellyfin: POST /Sessions/{id}/Playing
|
|
||||||
Note over Jellyfin: Start playback with queue
|
|
||||||
Manager->>Jellyfin: POST /Sessions/{id}/Playing/Seek
|
|
||||||
Note over Jellyfin: Seek to current position
|
|
||||||
Manager->>Player: Stop local playback
|
|
||||||
Manager->>Manager: Set mode to Remote
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Transfer to Local** (`transfer_to_local(item_id, position_ticks)`):
|
|
||||||
- Stops remote session playback
|
|
||||||
- Prepares local player to resume
|
|
||||||
- Sets mode to Local
|
|
||||||
|
|
||||||
**Tauri Commands** (`playback_mode.rs`):
|
|
||||||
- `playback_mode_get_current()` -> Returns current PlaybackMode
|
|
||||||
- `playback_mode_transfer_to_remote(session_id)` -> Async transfer
|
|
||||||
- `playback_mode_transfer_to_local(item_id, position_ticks)` -> Async transfer back
|
|
||||||
- `playback_mode_is_transferring()` -> Check transfer state
|
|
||||||
- `playback_mode_set(mode)` -> Direct mode setting
|
|
||||||
|
|
||||||
**Frontend Store** (`src/lib/stores/playbackMode.ts`):
|
|
||||||
- Thin wrapper calling Rust commands
|
|
||||||
- Maintains UI state (isTransferring, transferError)
|
|
||||||
- Listens to mode change events from Rust
|
|
||||||
|
|
||||||
## Database Service Abstraction
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/storage/db_service.rs`
|
|
||||||
|
|
||||||
Async database interface wrapping synchronous `rusqlite` to prevent blocking the Tokio runtime:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[async_trait]
|
|
||||||
pub trait DatabaseService: Send + Sync {
|
|
||||||
async fn execute(&self, query: Query) -> Result<usize, DatabaseError>;
|
|
||||||
async fn execute_batch(&self, queries: Vec<Query>) -> Result<(), DatabaseError>;
|
|
||||||
async fn query_one<T, F>(&self, query: Query, mapper: F) -> Result<T, DatabaseError>
|
|
||||||
where F: FnOnce(&Row) -> Result<T> + Send + 'static;
|
|
||||||
async fn query_optional<T, F>(&self, query: Query, mapper: F) -> Result<Option<T>, DatabaseError>
|
|
||||||
where F: FnOnce(&Row) -> Result<T> + Send + 'static;
|
|
||||||
async fn query_many<T, F>(&self, query: Query, mapper: F) -> Result<Vec<T>, DatabaseError>
|
|
||||||
where F: Fn(&Row) -> Result<T> + Send + 'static;
|
|
||||||
async fn transaction<F, T>(&self, f: F) -> Result<T, DatabaseError>
|
|
||||||
where F: FnOnce(Transaction) -> Result<T> + Send + 'static;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct RusqliteService {
|
|
||||||
connection: Arc<Mutex<Connection>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DatabaseService for RusqliteService {
|
|
||||||
async fn execute(&self, query: Query) -> Result<usize, DatabaseError> {
|
|
||||||
let conn = self.connection.clone();
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
// Execute query on blocking thread pool
|
|
||||||
}).await?
|
|
||||||
}
|
|
||||||
// ... other methods use spawn_blocking
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Benefits:**
|
|
||||||
- **No Freezing**: All blocking DB ops run in thread pool via `spawn_blocking`
|
|
||||||
- **Type Safety**: `QueryParam` enum prevents SQL injection
|
|
||||||
- **Future Proof**: Easy to swap to native async DB (tokio-rusqlite)
|
|
||||||
- **Testable**: Can mock DatabaseService for tests
|
|
||||||
|
|
||||||
**Usage Pattern:**
|
|
||||||
```rust
|
|
||||||
// Before (blocking - causes UI freeze)
|
|
||||||
let conn = database.connection();
|
|
||||||
let conn = conn.lock().unwrap(); // BLOCKS
|
|
||||||
conn.query_row(...) // BLOCKS
|
|
||||||
|
|
||||||
// After (async - no freezing)
|
|
||||||
let db_service = database.service();
|
|
||||||
let query = Query::with_params("SELECT ...", vec![...]);
|
|
||||||
db_service.query_one(query, |row| {...}).await // spawn_blocking internally
|
|
||||||
```
|
|
||||||
|
|
||||||
## Component Hierarchy
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
subgraph Routes["Routes (src/routes/)"]
|
|
||||||
LoginPage["Login Page"]
|
|
||||||
LibLayout["Library Layout"]
|
|
||||||
LibDetail["Album/Series Detail"]
|
|
||||||
MusicCategory["Music Category Landing"]
|
|
||||||
Tracks["Tracks"]
|
|
||||||
Artists["Artists"]
|
|
||||||
Albums["Albums"]
|
|
||||||
Playlists["Playlists"]
|
|
||||||
Genres["Genres"]
|
|
||||||
Downloads["Downloads Page"]
|
|
||||||
Settings["Settings Page"]
|
|
||||||
PlayerPage["Player Page"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph PlayerComps["Player Components"]
|
|
||||||
AudioPlayer["AudioPlayer"]
|
|
||||||
VideoPlayer["VideoPlayer"]
|
|
||||||
MiniPlayer["MiniPlayer"]
|
|
||||||
Controls["Controls"]
|
|
||||||
Queue["Queue"]
|
|
||||||
SleepTimerModal["SleepTimerModal"]
|
|
||||||
SleepTimerIndicator["SleepTimerIndicator"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph SessionComps["Sessions Components"]
|
|
||||||
CastButton["CastButton"]
|
|
||||||
SessionModal["SessionPickerModal"]
|
|
||||||
SessionCard["SessionCard"]
|
|
||||||
SessionsList["SessionsList"]
|
|
||||||
RemoteControls["RemoteControls"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph LibraryComps["Library Components"]
|
|
||||||
LibGrid["LibraryGrid"]
|
|
||||||
LibListView["LibraryListView"]
|
|
||||||
TrackList["TrackList"]
|
|
||||||
PlaylistDetail["PlaylistDetailView"]
|
|
||||||
DownloadBtn["DownloadButton"]
|
|
||||||
MediaCard["MediaCard"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph PlaylistComps["Playlist Components"]
|
|
||||||
CreatePlaylistModal["CreatePlaylistModal"]
|
|
||||||
AddToPlaylistModal["AddToPlaylistModal"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph CommonComps["Common Components"]
|
|
||||||
ScrollPicker["ScrollPicker"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph OtherComps["Other Components"]
|
|
||||||
Search["Search"]
|
|
||||||
FavoriteBtn["FavoriteButton"]
|
|
||||||
DownloadItem["DownloadItem"]
|
|
||||||
end
|
|
||||||
|
|
||||||
LibLayout --> PlayerComps
|
|
||||||
LibLayout --> LibDetail
|
|
||||||
MusicCategory --> Tracks
|
|
||||||
MusicCategory --> Artists
|
|
||||||
MusicCategory --> Albums
|
|
||||||
MusicCategory --> Playlists
|
|
||||||
MusicCategory --> Genres
|
|
||||||
LibDetail --> LibraryComps
|
|
||||||
Playlists --> PlaylistComps
|
|
||||||
Playlists --> PlaylistDetail
|
|
||||||
Downloads --> DownloadItem
|
|
||||||
PlayerPage --> PlayerComps
|
|
||||||
|
|
||||||
MiniPlayer --> CastButton
|
|
||||||
CastButton --> SessionModal
|
|
||||||
SleepTimerModal --> ScrollPicker
|
|
||||||
PlayerComps --> LibraryComps
|
|
||||||
```
|
|
||||||
|
|
||||||
## MiniPlayer Behavior
|
|
||||||
|
|
||||||
**Location**: `src/lib/components/player/MiniPlayer.svelte`
|
|
||||||
|
|
||||||
The MiniPlayer is a persistent bottom bar for audio playback that supports touch gestures and playback controls.
|
|
||||||
|
|
||||||
**Touch Gesture Handling:**
|
|
||||||
|
|
||||||
The MiniPlayer uses touch events to distinguish between taps (on controls) and swipe-up gestures (to expand to full player page):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function handleTouchStart(e: TouchEvent) {
|
|
||||||
touchStartX = e.touches[0].clientX;
|
|
||||||
touchStartY = e.touches[0].clientY;
|
|
||||||
touchEndX = touchStartX; // Initialize to start position
|
|
||||||
touchEndY = touchStartY; // Prevents taps being treated as swipes
|
|
||||||
isSwiping = true;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Design Decision**: `touchEndX`/`touchEndY` must be initialized to the start position in `handleTouchStart`. Without this, a pure tap (no `touchmove` event fired) would compute the swipe distance against (0,0), making every tap look like a massive swipe-up and inadvertently navigating to the player page.
|
|
||||||
|
|
||||||
**Skip Button State:**
|
|
||||||
|
|
||||||
The MiniPlayer's next/previous buttons are enabled based on `appState.hasNext`/`hasPrevious`, which are updated by `playerEvents.ts` calling `invoke("player_get_queue")` on every `StateChanged` event from the backend.
|
|
||||||
|
|
||||||
## Sleep Timer Architecture
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/sleep_timer.rs`, `src-tauri/src/player/mod.rs`
|
|
||||||
|
|
||||||
**TRACES**: UR-026 | DR-029
|
|
||||||
|
|
||||||
The sleep timer supports three modes for stopping playback:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
|
||||||
pub enum SleepTimerMode {
|
|
||||||
Off,
|
|
||||||
Time { end_time: i64 }, // Unix timestamp in milliseconds
|
|
||||||
EndOfTrack, // Stop after current track/episode
|
|
||||||
Episodes { remaining: u32 }, // Stop after N more episodes
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Timer Modes:**
|
|
||||||
|
|
||||||
| Mode | Trigger | How It Stops |
|
|
||||||
|------|---------|-------------|
|
|
||||||
| Time | User selects 15/30/45/60 min via roller UI | Background timer thread stops backend when `remaining_seconds == 0`; also checked at track boundaries in `on_playback_ended()` |
|
|
||||||
| EndOfTrack | User clicks "End of current track" | Checked in `on_playback_ended()`, returns `AutoplayDecision::Stop` |
|
|
||||||
| Episodes | User selects 1-10 episodes | `decrement_episode()` in `on_playback_ended()`, stops when counter reaches 0 |
|
|
||||||
|
|
||||||
**Time-Based Timer Flow:**
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant UI as SleepTimerModal
|
|
||||||
participant Store as sleepTimer store
|
|
||||||
participant Rust as PlayerController
|
|
||||||
participant Thread as Timer Thread
|
|
||||||
participant Backend as PlayerBackend
|
|
||||||
|
|
||||||
UI->>Store: setTimeTimer(30)
|
|
||||||
Store->>Rust: invoke("player_set_sleep_timer", {mode})
|
|
||||||
Rust->>Rust: Set SleepTimerMode::Time { end_time }
|
|
||||||
Rust->>UI: Emit SleepTimerChanged event
|
|
||||||
|
|
||||||
loop Every 1 second
|
|
||||||
Thread->>Thread: update_remaining_seconds()
|
|
||||||
Thread->>UI: Emit SleepTimerChanged (countdown)
|
|
||||||
alt remaining_seconds == 0
|
|
||||||
Thread->>Backend: stop()
|
|
||||||
Thread->>UI: Emit SleepTimerChanged (Off)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
**Frontend Components:**
|
|
||||||
|
|
||||||
- **ScrollPicker** (`src/lib/components/common/ScrollPicker.svelte`): Reusable scroll-wheel picker using CSS `scroll-snap-type: y mandatory`. Configurable items, visible count, and item height. Used by SleepTimerModal for time selection.
|
|
||||||
- **SleepTimerModal** (`src/lib/components/player/SleepTimerModal.svelte`): Modal with three sections - time picker (roller), end of track button, episode counter. Time section uses ScrollPicker with 15/30/45/60 min options. Accepts optional `mediaType` prop to override queue-based detection (used by VideoPlayer since video playback clears the audio queue).
|
|
||||||
- **SleepTimerIndicator** (`src/lib/components/player/SleepTimerIndicator.svelte`): Compact indicator showing active timer status with countdown.
|
|
||||||
- **Sleep buttons**: Clock icon buttons on AudioPlayer header, Controls bar, MiniPlayer, and VideoPlayer control bar. Shows clock icon when inactive, SleepTimerIndicator when active.
|
|
||||||
|
|
||||||
**Key Design Decisions:**
|
|
||||||
|
|
||||||
1. **All logic in Rust**: Frontend only displays state and invokes commands
|
|
||||||
2. **Background timer thread**: Handles time-based countdown independently of track boundaries
|
|
||||||
3. **Dual stop mechanism for Time mode**: Timer thread stops mid-track; `on_playback_ended()` catches edge case at track boundary
|
|
||||||
4. **Event-driven UI updates**: Timer thread emits `SleepTimerChanged` every second for countdown display
|
|
||||||
|
|
||||||
## Auto-Play Episode Limit
|
|
||||||
|
|
||||||
> ⚠️ **Autoplay is season-bounded.** `player/mod.rs:fetch_next_episode_for_item`
|
|
||||||
> does not cross a season boundary, so autoplay stops at the end of a season even
|
|
||||||
> though the "More Episodes" strip runs past it. Fixing it should reuse
|
|
||||||
> `repository_get_series_episodes`, but it touches the playback state machine and
|
|
||||||
> the Android JNI advance path (see the `AutoplayDecision` deadlock note in
|
|
||||||
> [CLAUDE.md](../../CLAUDE.md)) — its own change, not a drive-by.
|
|
||||||
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/mod.rs`, `src-tauri/src/player/autoplay.rs`, `src-tauri/src/settings.rs`
|
|
||||||
|
|
||||||
**TRACES**: UR-023 | DR-049
|
|
||||||
|
|
||||||
Limits how many episodes auto-play consecutively before requiring manual intervention.
|
|
||||||
|
|
||||||
**Settings:**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// In AutoplaySettings (runtime, in PlayerController)
|
|
||||||
pub struct AutoplaySettings {
|
|
||||||
pub enabled: bool,
|
|
||||||
pub countdown_seconds: u32,
|
|
||||||
pub max_episodes: u32, // 0 = unlimited
|
|
||||||
}
|
|
||||||
|
|
||||||
// In VideoSettings (persisted, settings page)
|
|
||||||
pub struct VideoSettings {
|
|
||||||
pub auto_play_next_episode: bool,
|
|
||||||
pub auto_play_countdown_seconds: u32,
|
|
||||||
pub auto_play_max_episodes: u32, // 0 = unlimited
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Session-Based Counter:**
|
|
||||||
|
|
||||||
The `autoplay_episode_count` field in `PlayerController` tracks consecutive auto-played episodes:
|
|
||||||
|
|
||||||
- **Incremented**: In `on_playback_ended()` when auto-playing next episode
|
|
||||||
- **Reset**: On any manual user action (`play_item()`, `play_queue()`, `next()`, `previous()`)
|
|
||||||
- **Limit check**: When `max_episodes > 0` and `count >= max_episodes`, the popup shows with `auto_advance: false` - user must manually click "Play Now" to continue
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
PlaybackEnded["on_playback_ended()"] --> CheckEpisode{"Is video<br/>episode?"}
|
|
||||||
CheckEpisode -->|"No"| AudioFlow["Audio queue logic"]
|
|
||||||
CheckEpisode -->|"Yes"| FetchNext["Fetch next episode"]
|
|
||||||
FetchNext --> IncrementCount["increment_autoplay_count()"]
|
|
||||||
IncrementCount --> CheckLimit{"max_episodes > 0<br/>AND count >= max?"}
|
|
||||||
CheckLimit -->|"No"| ShowPopup["ShowNextEpisodePopup<br/>auto_advance: true"]
|
|
||||||
CheckLimit -->|"Yes"| ShowPopupManual["ShowNextEpisodePopup<br/>auto_advance: false"]
|
|
||||||
ShowPopupManual --> UserClick["User clicks 'Play Now'"]
|
|
||||||
UserClick --> PlayItem["play_item() -> resets counter"]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Settings Sync:**
|
|
||||||
|
|
||||||
`VideoSettings` (settings page) and `AutoplaySettings` (PlayerController runtime) are synced via `player_set_video_settings`, which updates both the `VideoSettingsWrapper` state and calls `controller.set_autoplay_settings()`.
|
|
||||||
|
|
||||||
**Database**: Migration 016 adds `autoplay_max_episodes INTEGER DEFAULT 0` to `user_player_settings`.
|
|
||||||
|
|
||||||
**Settings UI**: Button grid with options: Unlimited, 1, 2, 3, 5, 10 episodes. Visible only when auto-play is enabled.
|
|
||||||
|
|
||||||
## Player Page Navigation Guard
|
|
||||||
|
|
||||||
**Location**: `src/routes/player/[id]/+page.svelte`
|
|
||||||
|
|
||||||
When the user navigates to the full player page (e.g., by swiping up on MiniPlayer), the `loadAndPlay` function checks whether the track is already playing before initiating new playback:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const alreadyPlayingMedia = get(storeCurrentMedia);
|
|
||||||
if (alreadyPlayingMedia?.id === id && !startPosition) {
|
|
||||||
// Track already playing - show UI without restarting playback
|
|
||||||
// Fetch queue status for hasNext/hasPrevious
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why This Matters**: Without this guard, navigating to the player page would restart playback with a single-track queue, destroying the existing album/playlist queue that the backend is playing. The Rust backend maintains the full queue (visible on the Android lock screen), but the frontend `loadAndPlay` function would overwrite it by calling `player_play_tracks` with just the current track.
|
|
||||||
|
|
||||||
## Playlist Management UI
|
|
||||||
|
|
||||||
**TRACES**: UR-014 | JA-019 | JA-020
|
|
||||||
|
|
||||||
**Location**: `src/lib/components/playlist/`, `src/lib/components/library/PlaylistDetailView.svelte`
|
|
||||||
|
|
||||||
The playlist UI provides full CRUD operations for Jellyfin playlists with offline sync support.
|
|
||||||
|
|
||||||
**Components:**
|
|
||||||
|
|
||||||
- **CreatePlaylistModal** (`src/lib/components/playlist/CreatePlaylistModal.svelte`):
|
|
||||||
- Modal for creating new playlists with a name input
|
|
||||||
- Accepts optional `initialItemIds` to pre-populate with tracks
|
|
||||||
- Keyboard support: Enter to create, Escape to close
|
|
||||||
- Navigates to new playlist detail page on creation
|
|
||||||
|
|
||||||
- **AddToPlaylistModal** (`src/lib/components/playlist/AddToPlaylistModal.svelte`):
|
|
||||||
- Modal listing all existing playlists to add tracks to
|
|
||||||
- "New Playlist" button for inline creation flow
|
|
||||||
- Shows playlist artwork via CachedImage
|
|
||||||
- Loading state with skeleton placeholders
|
|
||||||
|
|
||||||
- **PlaylistDetailView** (`src/lib/components/library/PlaylistDetailView.svelte`):
|
|
||||||
- Full playlist detail page with artwork, name, track count, total duration
|
|
||||||
- Click-to-rename with inline editing
|
|
||||||
- Play all / shuffle play buttons
|
|
||||||
- Delete with confirmation dialog
|
|
||||||
- Per-track removal buttons
|
|
||||||
- Uses `TrackList` component for track display
|
|
||||||
- Passes `{ type: "playlist", playlistId, playlistName }` context to player
|
|
||||||
|
|
||||||
- **Playlists Page** (`src/routes/library/music/playlists/+page.svelte`):
|
|
||||||
- Grid view using `GenericMediaListPage`
|
|
||||||
- Floating action button (FAB) to create new playlists
|
|
||||||
- Search by playlist name
|
|
||||||
|
|
||||||
**Frontend API Methods** (`src/lib/api/repository-client.ts`):
|
|
||||||
- `createPlaylist(name, itemIds?)` -> `PlaylistCreatedResult`
|
|
||||||
- `deletePlaylist(playlistId)`
|
|
||||||
- `renamePlaylist(playlistId, name)`
|
|
||||||
- `getPlaylistItems(playlistId)` -> `PlaylistEntry[]`
|
|
||||||
- `addToPlaylist(playlistId, itemIds)`
|
|
||||||
- `removeFromPlaylist(playlistId, entryIds)`
|
|
||||||
- `movePlaylistItem(playlistId, itemId, newIndex)`
|
|
||||||
|
|
||||||
**Offline Sync** (`src/lib/services/syncService.ts`):
|
|
||||||
All playlist mutations are queued for offline sync:
|
|
||||||
- `queuePlaylistCreate`, `queuePlaylistDelete`, `queuePlaylistRename`
|
|
||||||
- `queuePlaylistAddItems`, `queuePlaylistRemoveItems`, `queuePlaylistReorderItem`
|
|
||||||
|
|
||||||
## App Shell and Chrome
|
|
||||||
|
|
||||||
**Location**: `src/lib/utils/layoutShell.ts` (pure rules),
|
|
||||||
`src/lib/components/AppHeader.svelte`,
|
|
||||||
`src/lib/components/account/AccountMenu.svelte`, `BottomUi.svelte`
|
|
||||||
**TRACES**: UR-054 | DR-075, DR-076, DR-077
|
|
||||||
|
|
||||||
Account actions used to be reachable **only from `/library/*`** — the header
|
|
||||||
that hosted them belonged to the library layout, the bottom nav offered Home /
|
|
||||||
Search / Library, and the desktop username was inert text. From `/`, `/search`
|
|
||||||
or `/downloads` there was no route to Settings or Sign out at all. The header is
|
|
||||||
now shared and rendered from the root layout.
|
|
||||||
|
|
||||||
### Visibility rules
|
|
||||||
|
|
||||||
All four rules are pure functions in `layoutShell.ts`, so the contract is
|
|
||||||
unit-testable rather than a scattering of `$derived` booleans that drift per
|
|
||||||
route and platform (which is what they were):
|
|
||||||
|
|
||||||
| Function | Rule |
|
|
||||||
|----------|------|
|
|
||||||
| `showBottomNav` | Every authenticated route except `/player/*` and `/login` |
|
|
||||||
| `showGlobalMiniPlayer` | Everything except `/player/*`, `/login`, `/settings`. **Not** gated on platform or `/library` — the root owns the mini player everywhere, so the library route must never render a second one |
|
|
||||||
| `routeOwnsLayout` | `/library`, `/player/`, `/login` render their own full-height flex column; everything else renders into the root scroller |
|
|
||||||
| `showGlobalHeader` | Authenticated, not a layout-owning route, not `/settings` (the user is already there) |
|
|
||||||
|
|
||||||
### The structural fix worth not undoing
|
|
||||||
|
|
||||||
The "last row hidden behind the nav" bug is solved **structurally, not by
|
|
||||||
measurement**: the bottom UI is an in-flow flex child *below* the scroller
|
|
||||||
(`BottomUi.svelte`), so the scroller is physically bounded above it and cannot
|
|
||||||
render behind it. There is no measurement and no reserved padding. If you
|
|
||||||
restructure the shell, preserve the scroll containment — reintroducing padding
|
|
||||||
math reintroduces the bug.
|
|
||||||
|
|
||||||
### AccountMenu
|
|
||||||
|
|
||||||
One component for both breakpoints, anchored to the username/avatar (a real
|
|
||||||
button with `aria-expanded`, not a bare three-dot icon). Fixed item order:
|
|
||||||
identity block (user + server) → Downloads, Settings, Display → divider → Sign
|
|
||||||
out, destructive and last. Dismissal is backdrop click, `Escape`, and focus
|
|
||||||
return to the trigger.
|
|
||||||
|
|
||||||
The identity block falls back to the bare host of the server URL when the server
|
|
||||||
has no human-readable name, so it always shows *something* server-identifying.
|
|
||||||
|
|
||||||
Settings' Display section and the library page-header toggle are two views onto
|
|
||||||
the **same** persisted `viewMode` store (`jellytau-view-mode`) — no second state,
|
|
||||||
no migration, and they stay in sync for free.
|
|
||||||
|
|
||||||
## Library Mosaic
|
|
||||||
|
|
||||||
**Location**: `src/lib/components/library/libraryMosaic.ts` (pure),
|
|
||||||
`MosaicGrid.svelte`, `MosaicTile.svelte`
|
|
||||||
**TRACES**: UR-075, UR-067 | DR-174, DR-175
|
|
||||||
|
|
||||||
The library overview and the home "Your Libraries" strip are a **mosaic**, not a
|
|
||||||
grid: rows share one height and each tile is as wide as its own artwork is, so a
|
|
||||||
square music cover, a 16:9 library backdrop and a 2:3 poster sit in the same row
|
|
||||||
at their own proportions instead of all three being cropped into whichever box a
|
|
||||||
grid picked.
|
|
||||||
|
|
||||||
`libraryMosaic.ts` is deliberately pure — it takes the libraries and returns the
|
|
||||||
tiles to draw, so ordering and de-duplication are unit-testable rather than
|
|
||||||
buried in markup. Tiles start at an *assumed* aspect (square, 16:9) and a
|
|
||||||
measured image overrides it in `MosaicGrid`.
|
|
||||||
|
|
||||||
Note what this file does **not** decide: which favourites category a library
|
|
||||||
belongs to. That is Jellyfin vocabulary and arrives on the library itself as
|
|
||||||
`favoritesScope`, from `SearchScope::for_collection_type` in Rust (see
|
|
||||||
[01-rust-backend.md](01-rust-backend.md#search-scope-and-the-taxonomy-boundary)).
|
|
||||||
The frontend only decides what to *call* it and where to put it.
|
|
||||||
|
|
||||||
## Series and Episode Navigation
|
|
||||||
|
|
||||||
**Location**: `src/lib/components/library/` — `SeasonSection.svelte`,
|
|
||||||
`EpisodeFocusView.svelte`, `episodeStrip.ts` (pure)
|
|
||||||
**TRACES**: UR-062 … UR-064 | DR-101 … DR-107
|
|
||||||
|
|
||||||
Opening a series lands the viewer where they actually are in it. **"Where is this
|
|
||||||
viewer in this series" is resolved in Rust** (DR-101), not by the page: the
|
|
||||||
series detail page asks the repository and anchors on the answer — the current
|
|
||||||
season expanded, the current episode highlighted and scrolled into view, and a
|
|
||||||
hero button labelled `Resume S2E4` / `Play S1E1`.
|
|
||||||
|
|
||||||
A season is not a destination: `/library/<seasonId>` redirects to its series
|
|
||||||
(DR-103). Video library routes collapse to one per library (DR-105).
|
|
||||||
|
|
||||||
`episodeStrip.ts` holds the pure logic for the "More Episodes" strip, extracted
|
|
||||||
from the component because it had three distinct bugs that markup made
|
|
||||||
untestable: the strip collapsing to just the current episode while real siblings
|
|
||||||
existed, number-less episodes all matching as "current" (`undefined ===
|
|
||||||
undefined`), and the window dead-ending at a season boundary instead of running
|
|
||||||
past it. It matches by id first and only falls back to season+episode number when
|
|
||||||
both numbers are known on both sides.
|
|
||||||
|
|
||||||
## Downloaded Browse
|
|
||||||
|
|
||||||
**Location**: `src/lib/services/downloadedCatalog.ts`,
|
|
||||||
`src/lib/components/downloads/DownloadedBrowse.svelte`
|
|
||||||
**TRACES**: UR-055, UR-056 | DR-081 … DR-085
|
|
||||||
|
|
||||||
`/downloads` is two views: **Downloaded** (the default) — the library filtered to
|
|
||||||
what is on the device, reusing the same grids, cards and detail pages as online
|
|
||||||
browsing — and **Transfers**, the in-flight progress rows demoted to a secondary
|
|
||||||
tab.
|
|
||||||
|
|
||||||
`downloadedCatalog` reads the **offline-only** browse path on the repository,
|
|
||||||
never the hybrid merge. That is the point: an empty result means "nothing
|
|
||||||
downloaded here", never "server unreachable", so the view is authoritative
|
|
||||||
regardless of connectivity. It also owns disk usage — a per-item/container byte
|
|
||||||
map plus the device total, aggregated by the backend from `downloads.file_size`
|
|
||||||
(DR-085).
|
|
||||||
|
|
||||||
## Safe-area Insets
|
|
||||||
|
|
||||||
**Location**: `src/app.css`, `WindowInsetsBridge.kt`
|
|
||||||
**TRACES**: UR-066 | DR-112, IR-031
|
|
||||||
|
|
||||||
The Android WebView does not reliably report system-bar insets through
|
|
||||||
`env(safe-area-inset-*)`. Native `WindowInsets` (`systemBars() |
|
|
||||||
displayCutout()`) are therefore pushed in as CSS custom properties, and every
|
|
||||||
edge takes the larger of the two sources:
|
|
||||||
|
|
||||||
```css
|
|
||||||
--safe-top: max(env(safe-area-inset-top, 0px), var(--jt-inset-top, 0px));
|
|
||||||
```
|
|
||||||
|
|
||||||
Two rules keep this from going wrong: **one owner per edge** (two components both
|
|
||||||
padding the top edge double-pads it), and **no nested `h-screen`** — a full-height
|
|
||||||
child inside a full-height parent that has already consumed the inset overflows
|
|
||||||
by exactly the inset.
|
|
||||||
|
|
||||||
Unlike `addJavascriptInterface`, the inset push only writes CSS properties, so it
|
|
||||||
can safely be re-sent on resume.
|
|
||||||
|
|
||||||
## Stream Transport
|
|
||||||
|
|
||||||
**Location**: `src/lib/player/streamTransport.ts`
|
|
||||||
**TRACES**: UR-079 | DR-225 | UT-214
|
|
||||||
|
|
||||||
`videoLoaderFor(selection, capabilities)` picks the loader for the webview
|
|
||||||
`<video>` element — `hlsjs`, `nativeHls`, or `direct` — from the backend's tagged
|
|
||||||
`selection.transport`. `elementSrcFor` is its template companion: the element's
|
|
||||||
`src` is emptied only when hls.js is driving it.
|
|
||||||
|
|
||||||
The split is the point. **The transport is the stream's property and comes from
|
|
||||||
Rust; whether a given loader exists is the browser's, and is the only thing
|
|
||||||
decided here.**
|
|
||||||
|
|
||||||
> This replaced `currentStreamUrl.includes(".m3u8")`, which appeared twice in
|
|
||||||
> `VideoPlayer.svelte` — once in the HLS `$effect` and once inline in the
|
|
||||||
> template's `src`. Rust builds that URL and knows what it is; re-deriving it
|
|
||||||
> here by substring match was a domain fact reconstructed in the presentation
|
|
||||||
> layer, and it fails silently in both directions. The two tests that pin it are
|
|
||||||
> the ones that failed against the old implementation: a `progressive` stream
|
|
||||||
> whose URL contains `.m3u8` must **not** get an HLS loader, and an `hls` stream
|
|
||||||
> whose URL contains no `.m3u8` must.
|
|
||||||
>
|
|
||||||
> Logic lives in a plain `.ts` module rather than in the component for the usual
|
|
||||||
> reason — it is testable there. Same pattern as `episodeStrip.ts`.
|
|
||||||
|
|
||||||
`VideoPlayer` holds a `currentSelection`, not a URL string; `currentStreamUrl` is
|
|
||||||
derived from it. A reload replaces the selection **wholesale** (the adapter's
|
|
||||||
bridge takes a `StreamSelection`, not a URL), so transport and URL can never
|
|
||||||
drift apart. The background-audio handoff states the transport it is moving to —
|
|
||||||
progressive mp3 out, HLS back — via `selectionAt()`, rather than leaving it to be
|
|
||||||
inferred.
|
|
||||||
|
|
||||||
The quality picker is filled from `selection.available` (DR-227): rungs the
|
|
||||||
backend marked `exceedsSource` are not drawn, because they produce the same bytes
|
|
||||||
as `Original`. Nothing is optimistically assigned when the viewer picks a rung —
|
|
||||||
what the menu shows comes from the selection the backend hands back, since a
|
|
||||||
ceiling above the source bitrate *is* the source.
|
|
||||||
|
|
||||||
## Native Video Store
|
|
||||||
|
|
||||||
**Location**: `src/lib/stores/nativeVideo.ts`
|
|
||||||
**TRACES**: UR-003, UR-004 | DR-188
|
|
||||||
|
|
||||||
Two separate concerns live here, deliberately:
|
|
||||||
|
|
||||||
- `experimentalNativeVideo` — the user-facing opt-in flag, **defaulting to on**.
|
|
||||||
Rust already decides *which backend this platform has* (`useHtml5Element` from
|
|
||||||
`player_play_item`); this flag only *suppresses* that decision. It never turns
|
|
||||||
native on where Rust says HTML5. An explicit stored choice wins in both
|
|
||||||
directions, so someone who opted out is not re-enabled by a default flip —
|
|
||||||
hence the `null` check rather than a bare `=== "true"`.
|
|
||||||
- `nativeVideoActive` — whether a native surface is on screen *right now*.
|
|
||||||
Setting it toggles `data-native-video` on `<html>`, which is what the CSS in
|
|
||||||
`app.css` keys off to clear the app's opaque backgrounds. It is deliberately
|
|
||||||
**not** derived from the flag: the backgrounds must come back the moment the
|
|
||||||
player unmounts.
|
|
||||||
|
|
||||||
See [05-platform-backends.md](05-platform-backends.md#native-video-compositing-android)
|
|
||||||
for what is behind the WebView.
|
|
||||||
|
|
||||||
## Logging
|
|
||||||
|
|
||||||
**Location**: `src/lib/utils/logger.ts`
|
|
||||||
**TRACES**: DR-204
|
|
||||||
|
|
||||||
The frontend's equivalent of the Rust `log` crate: four levels
|
|
||||||
(`debug < info < warn < error`), a compile-environment default (dev → `debug`,
|
|
||||||
production → `warn`), and a runtime override that is the moral equivalent of
|
|
||||||
`RUST_LOG`. Scoped loggers carry the subsystem in the message, so a filtered
|
|
||||||
console stays usable while a player, a download worker and a store are all
|
|
||||||
talking.
|
|
||||||
|
|
||||||
Production deliberately keeps **warn and error**: this is a client talking to a
|
|
||||||
server that may or may not be there, and a silent failure is worse to support
|
|
||||||
than a noisy console. Only the chatter is suppressed.
|
|
||||||
|
|
||||||
`no-console` is an ESLint **error**, with the sink module itself the only
|
|
||||||
exception, so a raw `console.*` cannot re-appear.
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
# Data Flow
|
|
||||||
|
|
||||||
## Repository Query Flow (Cache-First)
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant UI as Svelte Component
|
|
||||||
participant Client as RepositoryClient (TS)
|
|
||||||
participant Rust as Tauri Command
|
|
||||||
participant Hybrid as HybridRepository
|
|
||||||
participant Cache as OfflineRepository (SQLite)
|
|
||||||
participant Server as OnlineRepository (HTTP)
|
|
||||||
participant Conn as ConnectivityMonitor
|
|
||||||
|
|
||||||
UI->>Client: getItems(parentId)
|
|
||||||
Client->>Rust: invoke("repository_get_items", {handle, parentId})
|
|
||||||
Rust->>Hybrid: get_items()
|
|
||||||
|
|
||||||
par Parallel Racing
|
|
||||||
Hybrid->>Cache: get_items() with 100ms timeout
|
|
||||||
Hybrid->>Server: get_items() (no timeout)
|
|
||||||
end
|
|
||||||
|
|
||||||
Note over Server,Conn: Every server request reports its outcome
|
|
||||||
alt Server succeeds (or answers with 4xx/5xx)
|
|
||||||
Server->>Conn: mark_reachable() (server is up)
|
|
||||||
else Network failure / timeout
|
|
||||||
Server->>Conn: mark_unreachable() (debounced)
|
|
||||||
end
|
|
||||||
|
|
||||||
alt Cache returns with content
|
|
||||||
Cache-->>Hybrid: Result with items
|
|
||||||
Hybrid-->>Rust: Return cache result
|
|
||||||
else Cache timeout or empty
|
|
||||||
Server-->>Hybrid: Fresh result
|
|
||||||
Hybrid-->>Rust: Return server result
|
|
||||||
end
|
|
||||||
|
|
||||||
Rust-->>Client: SearchResult
|
|
||||||
Client-->>UI: items[]
|
|
||||||
Note over UI: Reactive update
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Points:**
|
|
||||||
- Cache queries have 100ms timeout for responsiveness
|
|
||||||
- Server queries always run for fresh data
|
|
||||||
- Cache wins if it has meaningful content
|
|
||||||
- Automatic fallback to server if cache is empty/stale
|
|
||||||
- Background cache updates (planned)
|
|
||||||
- **Connectivity side-effect**: each server request feeds the `ConnectivityMonitor`, which is the source of truth for the offline/online banner (see [07-connectivity.md](07-connectivity.md)). A server-answered error (401/404/5xx) still counts as *reachable* — only network failures, sustained past a debounce window, flip the app to offline.
|
|
||||||
|
|
||||||
### Listing order is decided in Rust
|
|
||||||
|
|
||||||
**TRACES**: UR-007 | DR-257
|
|
||||||
|
|
||||||
A browse call names the **container** (`GetItemsOptions.parentKind`, the neutral
|
|
||||||
`MediaKind` the caller already holds) and not a sort field.
|
|
||||||
`default_listing_sort` in `repository/types.rs` turns that kind into the order:
|
|
||||||
|
|
||||||
| Container kind | Order |
|
|
||||||
|---|---|
|
|
||||||
| `channelFolder` — one podcast inside a plugin channel | `PremiereDate` descending |
|
|
||||||
| any other container | `SortName` ascending |
|
|
||||||
| none given | no `SortBy` — the server's own order stands |
|
|
||||||
|
|
||||||
Both legs of the race apply it, so the cached list does not flash in name order
|
|
||||||
before the server's arrives. An explicit `sortBy` from the caller always wins;
|
|
||||||
the default only fills the gap.
|
|
||||||
|
|
||||||
This is a domain rule, not a display preference, which is why it is not in the
|
|
||||||
frontend: the store that asks for a podcast's episodes has no business knowing
|
|
||||||
that podcasts are read newest-first. `MediaKind::ChannelFolder` exists for the
|
|
||||||
same reason — Jellyfin gives a channel container and an ordinary folder the same
|
|
||||||
item type (`ChannelFolderItem`), and while both mapped to `Folder` there was
|
|
||||||
nothing to key the rule on. The defect this prevents: every Jellypod podcast
|
|
||||||
listed alphabetically, which discarded the release order *and* clumped every
|
|
||||||
`[Played] …` episode at the top of the list.
|
|
||||||
|
|
||||||
## Search Flow (Locally Indexed)
|
|
||||||
|
|
||||||
**TRACES**: UR-065 | DR-108 … DR-111, IR-030
|
|
||||||
|
|
||||||
Search does not depend on a per-keystroke round trip to Jellyfin. The instant leg
|
|
||||||
reads the **local SQLite catalog**, which is already synced and already
|
|
||||||
FTS5-indexed, so results appear as fast as SQLite can answer — online or offline.
|
|
||||||
The server query stays, demoted to a background reconciliation that merges in
|
|
||||||
late results.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant UI as Search UI
|
|
||||||
participant Rust as repository_search
|
|
||||||
participant Cache as Local catalog (FTS5)
|
|
||||||
participant Server as Jellyfin
|
|
||||||
participant Indexer as spawn_catalog_indexer
|
|
||||||
|
|
||||||
UI->>Rust: search(query, scope)
|
|
||||||
Rust->>Cache: FTS5 query, scope expanded by SearchScope::item_types()
|
|
||||||
Cache-->>UI: instant results
|
|
||||||
Rust->>Server: reconciliation query (background)
|
|
||||||
Server-->>UI: search-event with late/merged results
|
|
||||||
Note over Indexer,Cache: Independent of any query:<br/>scheduled crawl keeps the index fresh,<br/>prunes items deleted on the server
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key points:**
|
|
||||||
|
|
||||||
- The **scope is opaque on the wire**. The frontend sends a `SearchScope`
|
|
||||||
variant; Rust expands it to item types
|
|
||||||
([01-rust-backend.md](01-rust-backend.md#search-scope-and-the-taxonomy-boundary)).
|
|
||||||
- **Index freshness is a Rust policy**, not a frontend startup call — a scheduled
|
|
||||||
background pass, not "whatever was synced when the app last launched"
|
|
||||||
(DR-109). See
|
|
||||||
[Background workers](01-rust-backend.md#background-workers).
|
|
||||||
- **Index hygiene matters as much as freshness**: the catalog save path uses
|
|
||||||
`INSERT OR REPLACE` and the crawl prunes rows for content deleted on the
|
|
||||||
server, or search keeps returning items that no longer exist (DR-110).
|
|
||||||
- The index covers **exactly the types the result groups render** (DR-111) —
|
|
||||||
including Artists, which the crawl must reach or the Artists group is silently
|
|
||||||
always empty.
|
|
||||||
|
|
||||||
**Deliberately not done, with reasons:**
|
|
||||||
|
|
||||||
- **Incremental indexing** (Jellyfin's `MinDateLastSaved`). A *full* crawl is
|
|
||||||
what makes the deletion sweep sound — it yields the authoritative id set per
|
|
||||||
library, and an incremental pass cannot detect deletions. Worth revisiting if
|
|
||||||
full crawls prove slow on large libraries; measure first.
|
|
||||||
- **Removing the server leg.** The reconciliation query stays.
|
|
||||||
|
|
||||||
> ⚠️ Two dead search implementations still exist: `storage_search_items`
|
|
||||||
> (`commands/storage/mod.rs`) and `offline_search` (`commands/offline.rs`). Both
|
|
||||||
> are registered in `lib.rs` and exported to `bindings.ts`; neither is called
|
|
||||||
> from the frontend. Deleting them is correct and unclaimed.
|
|
||||||
|
|
||||||
## Playback Initiation Flow
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant User
|
|
||||||
participant AudioPlayer
|
|
||||||
participant Tauri as Tauri IPC
|
|
||||||
participant Command as player_play_item()
|
|
||||||
participant Controller as PlayerController
|
|
||||||
participant Backend as PlayerBackend
|
|
||||||
participant Store as Frontend Store
|
|
||||||
|
|
||||||
User->>AudioPlayer: clicks play
|
|
||||||
AudioPlayer->>Tauri: invoke("player_play_item", {item})
|
|
||||||
Tauri->>Command: player_play_item()
|
|
||||||
Command->>Command: Convert PlayItemRequest -> MediaItem
|
|
||||||
Command->>Controller: play_item(item)
|
|
||||||
Controller->>Backend: load(item)
|
|
||||||
Note over Backend: State -> Loading
|
|
||||||
Controller->>Backend: play()
|
|
||||||
Note over Backend: State -> Playing
|
|
||||||
Controller-->>Command: Ok(())
|
|
||||||
Command-->>Tauri: PlayerStatus {state, position, duration, volume}
|
|
||||||
Tauri-->>AudioPlayer: status
|
|
||||||
AudioPlayer->>Store: player.setPlaying(media, position, duration)
|
|
||||||
Note over Store: UI updates reactively
|
|
||||||
```
|
|
||||||
|
|
||||||
## Video Stream Selection Flow
|
|
||||||
|
|
||||||
**TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228**
|
|
||||||
|
|
||||||
Before a video plays, Rust decides *what stream* — direct play, remux or
|
|
||||||
transcode, over which transport — and hands the player one self-describing
|
|
||||||
`StreamSelection`. The page no longer inspects the URL to work any of this out.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant Page as player/[id]/+page.svelte
|
|
||||||
participant Repo as HybridRepository
|
|
||||||
participant Online as OnlineRepository
|
|
||||||
participant Server as Jellyfin
|
|
||||||
participant VP as VideoPlayer.svelte
|
|
||||||
|
|
||||||
Page->>Repo: playerLocalMediaPath(id)
|
|
||||||
alt a completed download exists
|
|
||||||
Page->>Repo: mediaLocalSelection(path)
|
|
||||||
Note over Page: LocalFile / DirectPlay, no ladder —<br/>nothing about a file on disk re-negotiates
|
|
||||||
else stream from the server
|
|
||||||
Page->>Repo: getStreamSelection(id, mediaSourceId)
|
|
||||||
Repo->>Online: get_stream_selection()
|
|
||||||
Online->>Online: effective_streaming_quality()
|
|
||||||
Note over Online: per-playback override, else device default
|
|
||||||
Online->>Server: POST /Items/{id}/PlaybackInfo<br/>(device profile + ceiling)
|
|
||||||
Server-->>Online: MediaSource {supportsDirectPlay,<br/>supportsDirectStream, transcodingUrl, bitrate}
|
|
||||||
Online->>Online: decide_playback_kind()
|
|
||||||
alt Transcode
|
|
||||||
Online->>Online: adopt/stop prior play session,<br/>build HLS URL
|
|
||||||
Note over Online: Transport::Hls
|
|
||||||
else DirectPlay / DirectStream
|
|
||||||
Online->>Online: /Videos/{id}/stream?static=true
|
|
||||||
Note over Online: Transport::Progressive,<br/>rendition = None (it IS the source)
|
|
||||||
end
|
|
||||||
Online->>Online: quality_options_for_source(bitrate)
|
|
||||||
Online-->>Page: StreamSelection
|
|
||||||
end
|
|
||||||
Page->>VP: selection
|
|
||||||
VP->>VP: videoLoaderFor(selection, caps)
|
|
||||||
Note over VP: hls.js / native HLS / direct —<br/>from the tag, never from the URL
|
|
||||||
```
|
|
||||||
|
|
||||||
The selection travels with the stream from then on. A reload — a quality change,
|
|
||||||
an audio-track switch, a transcoded seek — returns a *new* selection through the
|
|
||||||
same tagged `strategy` response, so transport and URL can never disagree; and the
|
|
||||||
queue item carries the transport so `player_seek_video` picks its seek strategy
|
|
||||||
from the backend's decision rather than from the URL string.
|
|
||||||
|
|
||||||
## Playback Mode Transfer Flow
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant UI as Cast Button
|
|
||||||
participant Store as playbackMode store
|
|
||||||
participant Rust as Tauri Command
|
|
||||||
participant Manager as PlaybackModeManager
|
|
||||||
participant Player as PlayerController
|
|
||||||
participant Jellyfin as Jellyfin API
|
|
||||||
|
|
||||||
UI->>Store: transferToRemote(sessionId)
|
|
||||||
Store->>Rust: invoke("playback_mode_transfer_to_remote", {sessionId})
|
|
||||||
Rust->>Manager: transfer_to_remote()
|
|
||||||
|
|
||||||
Manager->>Player: Get current queue
|
|
||||||
Player-->>Manager: Vec<MediaItem>
|
|
||||||
Manager->>Manager: Extract Jellyfin IDs
|
|
||||||
|
|
||||||
Manager->>Jellyfin: POST /Sessions/{id}/Playing<br/>{itemIds, startIndex}
|
|
||||||
Jellyfin-->>Manager: 200 OK
|
|
||||||
|
|
||||||
Manager->>Jellyfin: POST /Sessions/{id}/Playing/Seek<br/>{positionTicks}
|
|
||||||
Jellyfin-->>Manager: 200 OK
|
|
||||||
|
|
||||||
Manager->>Player: stop()
|
|
||||||
Manager->>Manager: mode = Remote {sessionId}
|
|
||||||
|
|
||||||
Manager-->>Rust: Ok(())
|
|
||||||
Rust-->>Store: PlaybackMode
|
|
||||||
Store->>UI: Update cast icon
|
|
||||||
```
|
|
||||||
|
|
||||||
## Queue Navigation Flow
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
User["User clicks Next"] --> Invoke["invoke('player_next')"]
|
|
||||||
Invoke --> ControllerNext["controller.next()"]
|
|
||||||
ControllerNext --> QueueNext["queue.next()<br/>- Check repeat mode<br/>- Check shuffle<br/>- Update history"]
|
|
||||||
|
|
||||||
QueueNext --> None["None<br/>(at end)"]
|
|
||||||
QueueNext --> Some["Some(next)"]
|
|
||||||
QueueNext --> Same["Same<br/>(repeat one)"]
|
|
||||||
|
|
||||||
Some --> PlayItem["play_item(next)<br/>Returns new status"]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Volume Control Flow
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant User
|
|
||||||
participant Slider as Volume Slider
|
|
||||||
participant Handler as handleVolumeChange()
|
|
||||||
participant Tauri as Tauri IPC
|
|
||||||
participant Command as player_set_volume
|
|
||||||
participant Controller as PlayerController
|
|
||||||
participant Backend as MpvBackend/NullBackend
|
|
||||||
participant Events as playerEvents.ts
|
|
||||||
participant Store as Player Store
|
|
||||||
participant UI
|
|
||||||
|
|
||||||
User->>Slider: adjusts (0-100)
|
|
||||||
Slider->>Handler: oninput event
|
|
||||||
Handler->>Handler: Convert 0-100 -> 0.0-1.0
|
|
||||||
Handler->>Tauri: invoke("player_set_volume", {volume})
|
|
||||||
Tauri->>Command: player_set_volume
|
|
||||||
Command->>Controller: set_volume(volume)
|
|
||||||
Controller->>Backend: set_volume(volume)
|
|
||||||
Backend->>Backend: Clamp to 0.0-1.0
|
|
||||||
Note over Backend: MpvBackend: Send to MPV loop
|
|
||||||
Backend-->>Tauri: emit "player-event"
|
|
||||||
Tauri-->>Events: VolumeChanged event
|
|
||||||
Events->>Store: player.setVolume(volume)
|
|
||||||
Store-->>UI: Reactive update
|
|
||||||
Note over UI: Both AudioPlayer and<br/>MiniPlayer stay in sync
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Implementation Details:**
|
|
||||||
- Volume is stored in the backend (NullBackend/MpvBackend)
|
|
||||||
- `PlayerController.volume()` delegates to backend
|
|
||||||
- `get_player_status()` returns `controller.volume()` (not hardcoded)
|
|
||||||
- Frontend uses normalized 0.0-1.0 scale, UI shows 0-100
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
# Type Synchronization & Thread Safety
|
|
||||||
|
|
||||||
## PlayerState (Rust <-> TypeScript)
|
|
||||||
|
|
||||||
**Rust:**
|
|
||||||
```rust
|
|
||||||
pub enum PlayerState {
|
|
||||||
Idle,
|
|
||||||
Loading { media: MediaItem },
|
|
||||||
Playing { media: MediaItem, position: f64, duration: f64 },
|
|
||||||
Paused { media: MediaItem, position: f64, duration: f64 },
|
|
||||||
Seeking { media: MediaItem, target: f64 },
|
|
||||||
Error { media: Option<MediaItem>, error: String },
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**TypeScript:**
|
|
||||||
```typescript
|
|
||||||
type PlayerState =
|
|
||||||
| { kind: "idle" }
|
|
||||||
| { kind: "loading"; media: MediaItem }
|
|
||||||
| { kind: "playing"; media: MediaItem; position: number; duration: number }
|
|
||||||
| { kind: "paused"; media: MediaItem; position: number; duration: number }
|
|
||||||
| { kind: "seeking"; media: MediaItem; target: number }
|
|
||||||
| { kind: "error"; media: MediaItem | null; error: string };
|
|
||||||
```
|
|
||||||
|
|
||||||
## MediaItem Serialization
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Rust (serde serialization)
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
pub struct MediaItem {
|
|
||||||
pub id: String,
|
|
||||||
pub title: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub artist: Option<String>,
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// TypeScript
|
|
||||||
interface MediaItem {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
artist?: string;
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tauri v2 IPC Parameter Naming Convention
|
|
||||||
|
|
||||||
**CRITICAL**: Tauri v2's `#[tauri::command]` macro automatically converts snake_case Rust parameter names to camelCase for the frontend. All `invoke()` calls must use camelCase for top-level parameters.
|
|
||||||
|
|
||||||
**Rule**: Rust `fn cmd(repository_handle: String)` -> Frontend sends `{ repositoryHandle: "..." }`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// CORRECT - Tauri v2 auto-converts snake_case -> camelCase
|
|
||||||
await invoke("player_play_tracks", {
|
|
||||||
repositoryHandle: "handle-123", // Rust: repository_handle
|
|
||||||
request: { trackIds: ["id1"], startIndex: 0 }
|
|
||||||
});
|
|
||||||
|
|
||||||
await invoke("remote_send_command", {
|
|
||||||
sessionId: "session-123", // Rust: session_id
|
|
||||||
command: "PlayPause"
|
|
||||||
});
|
|
||||||
|
|
||||||
await invoke("pin_item", {
|
|
||||||
itemId: "item-123" // Rust: item_id
|
|
||||||
});
|
|
||||||
|
|
||||||
// WRONG - snake_case causes "invalid args request" error on Android
|
|
||||||
await invoke("player_play_tracks", {
|
|
||||||
repository_handle: "handle-123", // Will fail!
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Parameter Name Mapping (Rust -> Frontend)**:
|
|
||||||
|
|
||||||
| Rust Parameter | Frontend Parameter | Used By |
|
|
||||||
|----------------|-------------------|----|
|
|
||||||
| `repository_handle` | `repositoryHandle` | `player_play_tracks`, `player_add_track_by_id`, `player_play_album_track` |
|
|
||||||
| `session_id` | `sessionId` | `remote_send_command`, `remote_play_on_session`, `remote_session_seek` |
|
|
||||||
| `item_id` | `itemId` | `pin_item`, `unpin_item` |
|
|
||||||
| `current_item_id` | `currentItemId` | `playback_mode_transfer_to_local` |
|
|
||||||
| `position_ticks` | `positionTicks` | `playback_mode_transfer_to_local`, `remote_session_seek` |
|
|
||||||
| `item_ids` | `itemIds` | `remote_play_on_session` |
|
|
||||||
| `start_index` | `startIndex` | `remote_play_on_session` |
|
|
||||||
|
|
||||||
**Nested struct fields** use `#[serde(rename_all = "camelCase")]` separately - this is serde deserialization, not the command macro. Both layers convert independently.
|
|
||||||
|
|
||||||
**Test Coverage**: Integration tests in `src/lib/utils/tauriIntegration.test.ts` validate all invoke calls use correct camelCase parameter names.
|
|
||||||
|
|
||||||
## Rust Backend Thread Safety
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Shared state wrapped in Arc<Mutex<>>
|
|
||||||
pub struct PlayerController {
|
|
||||||
backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
|
|
||||||
queue: Arc<Mutex<QueueManager>>,
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tauri state wrapper
|
|
||||||
pub struct PlayerStateWrapper(pub Mutex<PlayerController>);
|
|
||||||
|
|
||||||
// Command handler pattern
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn player_play(state: State<PlayerStateWrapper>) -> Result<PlayerStatus, String> {
|
|
||||||
let mut controller = state.0.lock().unwrap(); // Acquire lock
|
|
||||||
controller.play()?; // Operate
|
|
||||||
Ok(get_player_status(&controller)) // Lock released
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Frontend Stores
|
|
||||||
|
|
||||||
Svelte stores are inherently reactive and thread-safe for UI updates:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const { subscribe, update } = writable<PlayerStore>(initialState);
|
|
||||||
|
|
||||||
// Atomic updates
|
|
||||||
function setPlaying(media: MediaItem, position: number, duration: number) {
|
|
||||||
update(state => ({
|
|
||||||
...state,
|
|
||||||
state: { kind: "playing", media, position, duration }
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -1,752 +0,0 @@
|
|||||||
# Platform-Specific Player Backends
|
|
||||||
|
|
||||||
## Player Events System
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/events.rs`
|
|
||||||
|
|
||||||
The player uses a push-based event system to notify the frontend of state changes:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub enum PlayerStatusEvent {
|
|
||||||
/// Playback position updated (emitted periodically during playback)
|
|
||||||
PositionUpdate { position: f64, duration: f64 },
|
|
||||||
|
|
||||||
/// Player state changed
|
|
||||||
StateChanged { state: String, media_id: Option<String> },
|
|
||||||
|
|
||||||
/// Media has finished loading and is ready to play
|
|
||||||
MediaLoaded { duration: f64 },
|
|
||||||
|
|
||||||
/// Playback has ended naturally
|
|
||||||
PlaybackEnded,
|
|
||||||
|
|
||||||
/// Buffering state changed
|
|
||||||
Buffering { percent: u8 },
|
|
||||||
|
|
||||||
/// An error occurred during playback
|
|
||||||
Error { message: String, recoverable: bool },
|
|
||||||
|
|
||||||
/// Volume changed
|
|
||||||
VolumeChanged { volume: f32, muted: bool },
|
|
||||||
|
|
||||||
/// Sleep timer state changed
|
|
||||||
SleepTimerChanged {
|
|
||||||
mode: SleepTimerMode,
|
|
||||||
remaining_seconds: u32,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Show next episode popup with countdown
|
|
||||||
ShowNextEpisodePopup {
|
|
||||||
current_episode: MediaItem,
|
|
||||||
next_episode: MediaItem,
|
|
||||||
countdown_seconds: u32,
|
|
||||||
auto_advance: bool,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Countdown tick (emitted every second during autoplay countdown)
|
|
||||||
CountdownTick { remaining_seconds: u32 },
|
|
||||||
|
|
||||||
/// Queue changed (items added, removed, reordered, or playback mode changed)
|
|
||||||
QueueChanged {
|
|
||||||
items: Vec<MediaItem>,
|
|
||||||
current_index: Option<usize>,
|
|
||||||
shuffle: bool,
|
|
||||||
repeat: RepeatMode,
|
|
||||||
has_next: bool,
|
|
||||||
has_previous: bool,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Media session changed (activity context changed: Audio/Movie/TvShow/Idle)
|
|
||||||
SessionChanged { session: MediaSessionType },
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Events are emitted via Tauri's event system:
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
subgraph Backend["Player Backend"]
|
|
||||||
MPV["MPV/ExoPlayer"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph EventSystem["Event System"]
|
|
||||||
Emitter["TauriEventEmitter<br/>emit()"]
|
|
||||||
Bus["Tauri Event Bus<br/>'player-event'"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Frontend["Frontend"]
|
|
||||||
Listener["playerEvents.ts<br/>Frontend Listener"]
|
|
||||||
Store["Player Store Update<br/>(position, state, etc)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
MPV --> Emitter --> Bus --> Listener --> Store
|
|
||||||
```
|
|
||||||
|
|
||||||
**Frontend Listener** (`src/lib/services/playerEvents.ts`):
|
|
||||||
- Listens for `player-event` Tauri events
|
|
||||||
- Updates player/queue stores based on event type
|
|
||||||
- Auto-advances to next track on `PlaybackEnded`
|
|
||||||
- On `StateChanged` events, calls `invoke("player_get_queue")` to update `appState.hasNext`/`hasPrevious` -- this enables MiniPlayer skip button state
|
|
||||||
|
|
||||||
**Important**: The command is `player_get_queue` (returns `QueueStatus` with `hasNext`/`hasPrevious`). There is no `player_get_queue_status` command.
|
|
||||||
|
|
||||||
## HTML5 Video Adapter (webview-rendered video)
|
|
||||||
|
|
||||||
**Location**: `src/lib/player/html5Adapter.ts`, `src/lib/player/index.ts`, report commands in
|
|
||||||
`src-tauri/src/commands/player/timers.rs`
|
|
||||||
|
|
||||||
Video on desktop (Linux WebKitGTK) is rendered by an HTML5 `<video>`/HLS element **inside the
|
|
||||||
webview**. Android no longer uses this path for video — see *The webview is not a video renderer on
|
|
||||||
Android* below. libmpv is initialized audio-only (`vo=null`,
|
|
||||||
`video=false`), so the native backend cannot render or observe this element. The `<video>` is therefore
|
|
||||||
the real player, living outside Rust's reach.
|
|
||||||
|
|
||||||
To keep the `PlayerController` the single source of truth (matching the audio path), the HTML5 element
|
|
||||||
is treated as **a dumb output device that reports back into Rust**, rather than an independent state
|
|
||||||
authority:
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
subgraph Webview["Webview"]
|
|
||||||
Video["HTML5 <video> / HLS.js"]
|
|
||||||
Adapter["html5Adapter.ts<br/>(reports DOM events)"]
|
|
||||||
end
|
|
||||||
subgraph Backend["Rust"]
|
|
||||||
Cmds["player_report_state<br/>player_report_position<br/>player_report_media_loaded"]
|
|
||||||
Controller["PlayerController"]
|
|
||||||
Emitter["TauriEventEmitter"]
|
|
||||||
end
|
|
||||||
subgraph Frontend["Frontend"]
|
|
||||||
Events["playerEvents.ts"]
|
|
||||||
Store["player store"]
|
|
||||||
end
|
|
||||||
|
|
||||||
Video -->|DOM events| Adapter --> Cmds --> Controller --> Emitter --> Events --> Store
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key points:**
|
|
||||||
- The adapter re-emits the *same* `PlayerStatusEvent`s (`StateChanged`, `PositionUpdate`, `MediaLoaded`)
|
|
||||||
the native backends emit, so `playerEvents.ts` needs **no** HTML5-specific branch — HTML5 is just
|
|
||||||
another event source feeding the existing pipeline.
|
|
||||||
- Position reports are throttled (~250ms) to match the MPV cadence and avoid flooding IPC from the
|
|
||||||
60fps RAF loop.
|
|
||||||
- **Boundary rule**: UI components never touch the report commands or `videoElement` state directly.
|
|
||||||
Playback *control* goes through the unified facade `src/lib/player/index.ts` (`playerController`);
|
|
||||||
HTML5 *state reporting* goes through `html5Adapter.ts`. This restores the documented invariant
|
|
||||||
("frontend only displays state and invokes commands") for the video path.
|
|
||||||
|
|
||||||
## MpvBackend (Linux)
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/mpv/`
|
|
||||||
|
|
||||||
The MPV backend uses libmpv for audio playback on Linux. Since MPV handles are not `Send`, all operations occur on a dedicated thread.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
subgraph MainThread["Main Thread"]
|
|
||||||
MpvBackend["MpvBackend<br/>- command_tx<br/>- shared_state<br/>- shutdown"]
|
|
||||||
Commands["Commands:<br/>Load, Play, Pause<br/>Stop, Seek, SetVolume"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph EventLoopThread["MPV Event Loop Thread"]
|
|
||||||
EventLoop["event_loop.rs<br/>- MPV Handle<br/>- command_rx<br/>- Event Emitter"]
|
|
||||||
TauriEmitter["TauriEventEmitter"]
|
|
||||||
end
|
|
||||||
|
|
||||||
MpvBackend -->|"MpvCommand"| EventLoop
|
|
||||||
MpvBackend <-->|"Arc<Mutex<>>"| EventLoop
|
|
||||||
EventLoop -->|"Events"| TauriEmitter
|
|
||||||
TauriEmitter --> FrontendStore["Frontend Store"]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Components:**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Command enum sent to event loop thread
|
|
||||||
pub enum MpvCommand {
|
|
||||||
Load { url: String, media: MediaItem },
|
|
||||||
Play,
|
|
||||||
Pause,
|
|
||||||
Stop,
|
|
||||||
Seek(f64),
|
|
||||||
SetVolume(f32),
|
|
||||||
Quit,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared state between main thread and event loop
|
|
||||||
pub struct MpvSharedState {
|
|
||||||
pub state: PlayerState,
|
|
||||||
pub position: f64,
|
|
||||||
pub duration: Option<f64>,
|
|
||||||
pub volume: f32,
|
|
||||||
pub is_loaded: bool,
|
|
||||||
pub current_media: Option<MediaItem>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Event Loop** (`event_loop.rs`):
|
|
||||||
- Initializes MPV with audio-only config (`vo=null`, `video=false`)
|
|
||||||
- Observes properties: `time-pos`, `duration`, `pause`, `volume`
|
|
||||||
- Emits position updates every 250ms during playback
|
|
||||||
- Processes commands from channel (non-blocking)
|
|
||||||
- Handles MPV events: `FileLoaded`, `EndFile`, `PropertyChange`
|
|
||||||
|
|
||||||
## ExoPlayerBackend (Android)
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/android/` and Kotlin sources
|
|
||||||
|
|
||||||
The ExoPlayer backend uses Android's Media3/ExoPlayer library via JNI.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
subgraph RustNative["Rust (Native)"]
|
|
||||||
ExoBackend["ExoPlayerBackend<br/>- player_ref<br/>- shared_state"]
|
|
||||||
NativeFuncs["JNI Callbacks<br/>nativeOnPosition...<br/>nativeOnState...<br/>nativeOnMediaLoaded<br/>nativeOnPlaybackEnd"]
|
|
||||||
TauriEmitter2["TauriEventEmitter"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph KotlinJVM["Kotlin (JVM)"]
|
|
||||||
JellyTauPlayer["JellyTauPlayer<br/>- ExoPlayer<br/>- Player.Listener"]
|
|
||||||
end
|
|
||||||
|
|
||||||
ExoBackend -->|"JNI Calls"| JellyTauPlayer
|
|
||||||
JellyTauPlayer -->|"Callbacks"| NativeFuncs
|
|
||||||
NativeFuncs --> TauriEmitter2
|
|
||||||
TauriEmitter2 --> FrontendStore2["Frontend Store"]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Kotlin Player** (`JellyTauPlayer.kt`):
|
|
||||||
```kotlin
|
|
||||||
class JellyTauPlayer(context: Context) {
|
|
||||||
private val exoPlayer: ExoPlayer
|
|
||||||
private var positionUpdateJob: Job?
|
|
||||||
|
|
||||||
// Methods callable from Rust via JNI
|
|
||||||
fun load(url: String, mediaId: String)
|
|
||||||
fun play()
|
|
||||||
fun pause()
|
|
||||||
fun stop()
|
|
||||||
fun seek(positionSeconds: Double)
|
|
||||||
fun setVolume(volume: Float)
|
|
||||||
|
|
||||||
// Native callbacks to Rust
|
|
||||||
private external fun nativeOnPositionUpdate(position: Double, duration: Double)
|
|
||||||
private external fun nativeOnStateChanged(state: String, mediaId: String?)
|
|
||||||
private external fun nativeOnMediaLoaded(duration: Double)
|
|
||||||
private external fun nativeOnPlaybackEnded()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**JNI Callbacks** (Rust):
|
|
||||||
```rust
|
|
||||||
#[no_mangle]
|
|
||||||
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPositionUpdate(
|
|
||||||
_env: JNIEnv, _class: JClass, position: jdouble, duration: jdouble
|
|
||||||
) {
|
|
||||||
// Update shared state
|
|
||||||
// Emit PlayerStatusEvent::PositionUpdate
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Audio settings on ExoPlayer
|
|
||||||
|
|
||||||
**TRACES**: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
|
|
||||||
|
|
||||||
`PlayerBackend` declares `set_audio_settings` with a default `Ok(())` body. For a
|
|
||||||
long time `ExoPlayerBackend` took that default, so Settings › Audio rendered
|
|
||||||
controls that silently did nothing on Android — the parity gap recorded in
|
|
||||||
[requirements.md](../requirements.md#platform-playback-backend-parity-linux-vs-android),
|
|
||||||
now closed.
|
|
||||||
|
|
||||||
The settings cross to Kotlin as **JSON over JNI**, not as a wide signature, so new
|
|
||||||
fields do not change the method signature — the same approach `load()` uses for
|
|
||||||
subtitles:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
|
|
||||||
let json = audio_settings_jni_payload(settings)?;
|
|
||||||
env.call_method(&self.player_ref, "setAudioSettings", "(Ljava/lang/String;)V", …)?;
|
|
||||||
// Store the sanitised form, so audio_settings() reflects what was applied.
|
|
||||||
self.shared_state.lock_safe().audio_settings =
|
|
||||||
settings.clone().with_crossfade_clamped().with_equalizer_normalised();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Kotlin owns the *mechanics* — attaching `AudioEffect`s to the audio session — while
|
|
||||||
the canonical band layout and preset curves stay in Rust:
|
|
||||||
|
|
||||||
| Feature | Android mechanism | Notes |
|
|
||||||
|---------|-------------------|-------|
|
|
||||||
| Gapless | `pauseAtEndOfMediaItems` | |
|
|
||||||
| Volume normalization | `LoudnessEnhancer` | A gain stage — approximate next to MPV's `dynaudnorm` |
|
|
||||||
| Equalizer | `android.media.audiofx.Equalizer` | The canonical 10 bands are resampled onto the device's own band centres |
|
|
||||||
| Crossfade | — | Unimplemented on **every** platform (DR-034), architecturally blocked on MPV. Building it on Android alone would invert the parity gap |
|
|
||||||
|
|
||||||
Two things are deliberately still open: the effects are **not yet verified on a
|
|
||||||
physical device** (`AudioEffect` availability and band layouts are device-specific),
|
|
||||||
and the trait default is still a silent `Ok(())` rather than an error, so a backend
|
|
||||||
that omits the method still reports success. Flipping that default waits on the
|
|
||||||
device verification.
|
|
||||||
|
|
||||||
### Licensed audio codecs: the FFmpeg extension
|
|
||||||
|
|
||||||
**TRACES**: UR-004, UR-071 | DR-293
|
|
||||||
|
|
||||||
Android does not ship AC-3, E-AC-3, DTS or TrueHD decoders — they are licensed
|
|
||||||
codecs, present only where a vendor paid for them. The ROD2-W09 test tablet has a
|
|
||||||
vendor DTS decoder and no AC-3/E-AC-3 at all. ExoPlayer has no decoders of its
|
|
||||||
own, so on such a device those tracks are undecodable, and before this every film
|
|
||||||
with Dolby audio was re-encoded by the server — for streaming *and* for download.
|
|
||||||
|
|
||||||
`JellyTauPlayer` builds ExoPlayer with `DefaultRenderersFactory` in
|
|
||||||
`EXTENSION_RENDERER_MODE_ON`: the platform's decoders are tried first (a vendor DTS
|
|
||||||
decoder stays in charge where there is one) and the FFmpeg audio renderer takes
|
|
||||||
what they cannot decode. `CodecDetector` reports the extension's codecs beside the
|
|
||||||
`MediaCodecList` ones, asking `FfmpegLibrary.supportsFormat` per MIME type rather
|
|
||||||
than assuming, so a build whose native library failed to load reports only what
|
|
||||||
the platform decodes. Rust's device profile and download policy read that list,
|
|
||||||
which is what keeps "what we tell the server" and "what actually decodes" in step.
|
|
||||||
|
|
||||||
The decoder is `org.jellyfin.media3:media3-ffmpeg-decoder` — Jellyfin's build of
|
|
||||||
media3's FFmpeg extension, versioned `<media3 version>+N`. **Bump it in the same
|
|
||||||
commit as media3.** It is GPL-3.0: the distributed APK carries those terms, the
|
|
||||||
source stays MIT (see `THIRD_PARTY_NOTICES.md`). Its JNI methods are covered by the
|
|
||||||
AAR's own consumer rules and by `-keep class androidx.media3.** { *; }` in
|
|
||||||
`proguard-jellytau.pro`, which also keeps the renderer ExoPlayer loads reflectively.
|
|
||||||
|
|
||||||
**Rejected:** re-encoding a download's audio on the device after it lands (a
|
|
||||||
remux). It costs minutes of CPU and twice the disk per film, needs a pipeline
|
|
||||||
state of its own, and does nothing for streaming. Decoding at playback fixes both
|
|
||||||
paths with no extra step.
|
|
||||||
|
|
||||||
### The webview is not a video renderer on Android
|
|
||||||
|
|
||||||
ExoPlayer is Android's only video renderer. The HTML5 path used to be reachable
|
|
||||||
through the `experimentalNativeVideo` setting (a *suppressor* of Rust's native
|
|
||||||
choice), but the webview decodes none of the codecs above — so with the original
|
|
||||||
file now downloaded as-is (DR-293), turning native video off would play every such
|
|
||||||
download as a silent film. Rust reports `webview_video_fallback` in
|
|
||||||
`PlaybackCapabilities`: **false on Android**, true only beside mpv native video on
|
|
||||||
Linux, where the webview is still the tested fallback. The frontend offers the
|
|
||||||
switch and honours a stored "off" only when it is true (`nativeVideoWanted` in
|
|
||||||
`stores/nativeVideo.ts`), so a user who once switched it off on Android is not
|
|
||||||
stranded on the silent path.
|
|
||||||
|
|
||||||
### The equalizer, and where its vocabulary lives
|
|
||||||
|
|
||||||
**TRACES**: UR-027 | DR-030, IR-020
|
|
||||||
|
|
||||||
The canonical band layout (`EQ_BANDS`) and the preset curves live in
|
|
||||||
`settings.rs`, **not** in either backend and not in the UI: a preset *is* a gain
|
|
||||||
curve defined by the band layout, and the layout is a property of the audio
|
|
||||||
engine rather than of the picker that renders it. Presets are Flat, Rock, Pop,
|
|
||||||
Jazz, Classical, Bass Boost, Treble Boost and Vocal, all conservative (within
|
|
||||||
±8 dB) so they stack safely with volume normalization.
|
|
||||||
|
|
||||||
| Platform | Mechanism |
|
|
||||||
|----------|-----------|
|
|
||||||
| Linux | One ffmpeg two-pole peaking `equalizer` filter per band, composed by `build_af_filter` into MPV's `af` property alongside the normalization filter: `equalizer=f=31:width_type=o:width=1:g=5` |
|
|
||||||
| Android | `android.media.audiofx.Equalizer`, with the canonical 10 bands **resampled onto whatever band centres the device actually has** |
|
|
||||||
|
|
||||||
Gains are normalised (`with_equalizer_normalised`) before use, and bands beyond
|
|
||||||
`EQ_BANDS` are ignored, so a malformed settings payload cannot produce a filter
|
|
||||||
chain of unbounded length.
|
|
||||||
|
|
||||||
## Background Audio Handoff (Android)
|
|
||||||
|
|
||||||
**TRACES**: UR-040 | IR-025, DR-051, DR-052, DR-178 … DR-180, DR-196, DR-203
|
|
||||||
|
|
||||||
Keeping a video's **audio** alive when the app is backgrounded or the screen
|
|
||||||
locks, while video decode stops. Two verified facts drive the whole design:
|
|
||||||
|
|
||||||
1. An Android WebView `<video>` **does not** keep playing audio once the app is
|
|
||||||
backgrounded — the system throttles the WebView and media pauses.
|
|
||||||
2. Keeping audio alive in the background requires a **native foreground media
|
|
||||||
service**, which already exists for music (`JellyTauPlaybackService` +
|
|
||||||
`JellyTauPlayer` + `MediaSessionCompat`).
|
|
||||||
|
|
||||||
So this is a **handoff**, not "keep the WebView alive": on background, tear down
|
|
||||||
the current renderer and play the same item audio-only through the native
|
|
||||||
service; on foreground, hand back. In the project's one-directional playback
|
|
||||||
model this is a change of *which player is authoritative*, and the position must
|
|
||||||
transfer cleanly across it.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant App as App backgrounded
|
|
||||||
participant FE as VideoPlayer
|
|
||||||
participant Rust as player_enter/exit_background_audio
|
|
||||||
participant Exo as Native audio service
|
|
||||||
|
|
||||||
App-->>FE: jellytau-background (DOM CustomEvent)
|
|
||||||
FE->>Rust: enter(item, position, audioStreamIndex)
|
|
||||||
Rust->>Exo: play audio-only at position
|
|
||||||
Note over Exo: lockscreen + notification, existing MediaSession
|
|
||||||
App-->>FE: jellytau-foreground
|
|
||||||
FE->>Rust: exit() -> final position
|
|
||||||
Rust-->>FE: position
|
|
||||||
FE->>FE: restart the renderer that is on screen
|
|
||||||
```
|
|
||||||
|
|
||||||
Details that were each a shipped defect:
|
|
||||||
|
|
||||||
- **Position is absolute.** Transcoded HLS tracks time as
|
|
||||||
`videoElement.currentTime + seekOffset` (the element resets to 0 after each
|
|
||||||
transcode reload). `computeHandoffPosition` sums both terms; using the element
|
|
||||||
time alone rewinds by the offset.
|
|
||||||
- **A downloaded episode takes no base URL and an ordinary seek** (DR-180); a
|
|
||||||
stream takes the base and no seek; a handoff at 0:00 takes neither.
|
|
||||||
- **The return must restart the renderer that is actually on screen** (DR-196).
|
|
||||||
The two paths resume by different means — the webview `<video>` reloads off its
|
|
||||||
stream URL, watched by an `$effect`; ExoPlayer owns no element and nothing
|
|
||||||
watches the URL for it, so it needs an explicit re-issue. Doing only the URL
|
|
||||||
assignment restarted nothing on the native path and left a black screen with a
|
|
||||||
play button that did nothing.
|
|
||||||
- **`wasPlaying` is captured on the way out** so play/pause survives the round
|
|
||||||
trip, and the handoff does not silently rewind (DR-203).
|
|
||||||
- **Mutually exclusive with PiP.** Toggle on → `setAutoEnterEnabled(false)`;
|
|
||||||
toggle off → PiP on background, the status quo. The frontend re-asserts the
|
|
||||||
value whenever the toggle changes and on unmount, so a stale setting cannot
|
|
||||||
leak into the next player.
|
|
||||||
- The pure arithmetic and state transitions live in
|
|
||||||
`backgroundAudioHandoff.ts`, free of Svelte and the DOM, so they are testable
|
|
||||||
without mounting the player.
|
|
||||||
|
|
||||||
Native signals background/foreground to the frontend as DOM CustomEvents
|
|
||||||
(`jellytau-background` / `jellytau-foreground`); the frontend carries the toggle
|
|
||||||
state to native through the `AndroidBackgroundAudio` bridge. No-op on every
|
|
||||||
non-Android platform.
|
|
||||||
|
|
||||||
## Native Video Compositing (Android)
|
|
||||||
|
|
||||||
**TRACES**: UR-003, UR-004 | DR-150 … DR-152, DR-182 … DR-196
|
|
||||||
|
|
||||||
Android can render video on the **native ExoPlayer surface behind a transparent
|
|
||||||
Tauri WebView**, with the Svelte controls drawn over it. This is on by default;
|
|
||||||
the HTML5 `<video>` path remains the fallback and is not being removed. The
|
|
||||||
default has been flipped and reverted twice and each revert has a named cause —
|
|
||||||
the per-defect record is in `requirements.md` (DR-150 … DR-196).
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
subgraph Window["One Android window"]
|
|
||||||
Texture["TextureView (index 0)<br/>ExoPlayer video"]
|
|
||||||
WebView["Tauri WebView (above)<br/>transparent, Svelte controls"]
|
|
||||||
end
|
|
||||||
Rust["ExoPlayerBackend"] -->|JNI| Player["JellyTauPlayer"]
|
|
||||||
Player --> Texture
|
|
||||||
MainActivity -->|"setTransparent(true)"| WebView
|
|
||||||
VideoOverlayManager -->|"attach / detach"| Texture
|
|
||||||
```
|
|
||||||
|
|
||||||
Load-bearing details, each of which was a shipped defect:
|
|
||||||
|
|
||||||
- **TextureView, not SurfaceView** (DR-192). A SurfaceView renders on its own
|
|
||||||
layer *outside* the app window and punches a transparent hole through it;
|
|
||||||
everything drawn above that hole — for us the whole UI — depends on that
|
|
||||||
composition path, which Android's own documentation says does not reliably
|
|
||||||
work. A TextureView makes "behind" ordinary view z-order within one window.
|
|
||||||
- **Attached at index 0** by `VideoOverlayManager`, and **detached when the video
|
|
||||||
goes** (DR-184) — a surface left in the hierarchy outlives its player.
|
|
||||||
- **Bridges are installed before the page that uses them** (DR-183).
|
|
||||||
`addJavascriptInterface` must run once per WebView instance and a call that
|
|
||||||
lands after the page has loaded never reaches it, so `setTransparent(true)`
|
|
||||||
could be dropped entirely.
|
|
||||||
- **The app shell stops painting over the surface** (DR-185). `app.css` clears
|
|
||||||
its opaque backgrounds off `[data-native-video]`; before that, a CSS rule
|
|
||||||
targeted an attribute nothing ever set, so the fix looked applied and was not.
|
|
||||||
- **The poster card can lift on a path with no `<video>` element** (DR-182) — the
|
|
||||||
native reveal fires on a `playing` state or a position tick carrying a position
|
|
||||||
or duration, and on nothing else.
|
|
||||||
- **Letterbox bars are painted**, not left holding whatever was last in the
|
|
||||||
framebuffer (DR-194).
|
|
||||||
- There is deliberately **no audio-focus bridge**: manual focus requests from the
|
|
||||||
WebView competed with Chromium's `AudioFocusDelegate` and with ExoPlayer, and
|
|
||||||
the resulting `AUDIOFOCUS_LOSS` paused playback.
|
|
||||||
|
|
||||||
Related Kotlin pieces in the same window: `PictureInPictureManager` (DR-160/161),
|
|
||||||
`ScreenWakeManager` (DR-202 — Android counts its display timeout from touch
|
|
||||||
events, which a playing video does not generate), `ImmersiveModeBridge` and
|
|
||||||
`WindowInsetsBridge` (IR-031/DR-112 — see
|
|
||||||
[02-svelte-frontend.md](02-svelte-frontend.md#safe-area-insets)).
|
|
||||||
|
|
||||||
## Android MediaSession & Remote Volume Control
|
|
||||||
|
|
||||||
**Location**: `JellyTauPlaybackService.kt`
|
|
||||||
|
|
||||||
JellyTau uses a dual MediaSession architecture for Android to support both Media3 playback controls and remote volume control:
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
subgraph Service["JellyTauPlaybackService"]
|
|
||||||
MediaSession["Media3 MediaSession<br/>- Lockscreen controls<br/>- Media notifications<br/>- Play/Pause/Next/Previous"]
|
|
||||||
|
|
||||||
MediaSessionCompat["MediaSessionCompat<br/>- Remote volume control<br/>- Hardware button interception"]
|
|
||||||
|
|
||||||
VolumeProvider["VolumeProviderCompat<br/>- onSetVolumeTo()<br/>- onAdjustVolume()"]
|
|
||||||
|
|
||||||
MediaSessionCompat --> VolumeProvider
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Hardware["System"]
|
|
||||||
VolumeButtons["Hardware Volume Buttons"]
|
|
||||||
Lockscreen["Lockscreen Controls"]
|
|
||||||
Notification["Media Notification"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Rust["Rust Backend"]
|
|
||||||
JNI["JNI Callbacks<br/>nativeOnRemoteVolumeChange()"]
|
|
||||||
PlaybackMode["PlaybackModeManager<br/>send_remote_volume_command()"]
|
|
||||||
JellyfinAPI["Jellyfin API<br/>session_set_volume()"]
|
|
||||||
end
|
|
||||||
|
|
||||||
VolumeButtons --> VolumeProvider
|
|
||||||
Lockscreen --> MediaSession
|
|
||||||
Notification --> MediaSession
|
|
||||||
|
|
||||||
VolumeProvider --> JNI
|
|
||||||
JNI --> PlaybackMode
|
|
||||||
PlaybackMode --> JellyfinAPI
|
|
||||||
```
|
|
||||||
|
|
||||||
**Architecture Rationale:**
|
|
||||||
|
|
||||||
JellyTau maintains both MediaSession types because they serve different purposes:
|
|
||||||
|
|
||||||
1. **Media3 MediaSession**: Handles lockscreen/notification playback controls (play/pause/next/previous)
|
|
||||||
2. **MediaSessionCompat**: Intercepts hardware volume button presses for remote playback control
|
|
||||||
|
|
||||||
When in remote playback mode (controlling a Jellyfin session on another device):
|
|
||||||
- Volume buttons are routed through `VolumeProviderCompat`
|
|
||||||
- Volume changes are sent to the remote session via Jellyfin API
|
|
||||||
- System volume UI shows the remote session's volume level
|
|
||||||
|
|
||||||
**Remote Volume Flow:**
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant User
|
|
||||||
participant VolumeButton as Hardware Volume Button
|
|
||||||
participant VolumeProvider as VolumeProviderCompat
|
|
||||||
participant JNI as nativeOnRemoteVolumeChange
|
|
||||||
participant PlaybackMode as PlaybackModeManager
|
|
||||||
participant Jellyfin as Jellyfin Server
|
|
||||||
participant RemoteSession as Remote Session (TV/Browser)
|
|
||||||
|
|
||||||
User->>VolumeButton: Press Volume Up
|
|
||||||
VolumeButton->>VolumeProvider: onAdjustVolume(ADJUST_RAISE)
|
|
||||||
VolumeProvider->>VolumeProvider: remoteVolumeLevel += 2
|
|
||||||
VolumeProvider->>VolumeProvider: currentVolume = remoteVolumeLevel
|
|
||||||
VolumeProvider->>JNI: nativeOnRemoteVolumeChange("VolumeUp", level)
|
|
||||||
JNI->>PlaybackMode: send_remote_volume_command("VolumeUp", level)
|
|
||||||
PlaybackMode->>Jellyfin: POST /Sessions/{id}/Command/VolumeUp
|
|
||||||
Jellyfin->>RemoteSession: Set volume to new level
|
|
||||||
RemoteSession-->>User: Volume changes on TV/Browser
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Implementation Details:**
|
|
||||||
|
|
||||||
**Enabling Remote Volume** (`enableRemoteVolume()`):
|
|
||||||
```kotlin
|
|
||||||
fun enableRemoteVolume(initialVolume: Int) {
|
|
||||||
volumeProvider = object : VolumeProviderCompat(
|
|
||||||
VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE,
|
|
||||||
100, // Max volume
|
|
||||||
initialVolume
|
|
||||||
) {
|
|
||||||
override fun onSetVolumeTo(volume: Int) {
|
|
||||||
remoteVolumeLevel = volume.coerceIn(0, 100)
|
|
||||||
nativeOnRemoteVolumeChange("SetVolume", remoteVolumeLevel)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAdjustVolume(direction: Int) {
|
|
||||||
when (direction) {
|
|
||||||
AudioManager.ADJUST_RAISE -> {
|
|
||||||
remoteVolumeLevel = (remoteVolumeLevel + 2).coerceAtMost(100)
|
|
||||||
nativeOnRemoteVolumeChange("VolumeUp", remoteVolumeLevel)
|
|
||||||
currentVolume = remoteVolumeLevel
|
|
||||||
}
|
|
||||||
AudioManager.ADJUST_LOWER -> {
|
|
||||||
remoteVolumeLevel = (remoteVolumeLevel - 2).coerceAtLeast(0)
|
|
||||||
nativeOnRemoteVolumeChange("VolumeDown", remoteVolumeLevel)
|
|
||||||
currentVolume = remoteVolumeLevel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaSessionCompat.setPlaybackToRemote(volumeProvider)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Disabling Remote Volume** (`disableRemoteVolume()`):
|
|
||||||
```kotlin
|
|
||||||
fun disableRemoteVolume() {
|
|
||||||
mediaSessionCompat.setPlaybackToLocal(AudioManager.STREAM_MUSIC)
|
|
||||||
volumeProvider = null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rust Integration** (`src-tauri/src/player/android/mod.rs`):
|
|
||||||
```rust
|
|
||||||
/// Enable remote volume control on Android
|
|
||||||
pub fn enable_remote_volume(initial_volume: i32) -> Result<(), String> {
|
|
||||||
start_playback_service()?;
|
|
||||||
let service_instance = get_playback_service_instance()?;
|
|
||||||
env.call_method(&service_instance, "enableRemoteVolume", "(I)V",
|
|
||||||
&[JValue::Int(initial_volume)])?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Dependencies** (`src-tauri/android/build.gradle.kts`):
|
|
||||||
```kotlin
|
|
||||||
dependencies {
|
|
||||||
implementation("androidx.media3:media3-session:1.5.1") // Media3 MediaSession
|
|
||||||
implementation("androidx.media:media:1.7.0") // MediaSessionCompat
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Integration with Playback Mode:**
|
|
||||||
|
|
||||||
Remote volume is automatically enabled/disabled during playback mode transfers:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// In PlaybackModeManager::transfer_to_remote()
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
{
|
|
||||||
if let Err(e) = crate::player::enable_remote_volume(50) {
|
|
||||||
log::warn!("Failed to enable remote volume: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// In PlaybackModeManager::transfer_to_local()
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
{
|
|
||||||
if let Err(e) = crate::player::disable_remote_volume() {
|
|
||||||
log::warn!("Failed to disable remote volume: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Android Album Art Caching
|
|
||||||
|
|
||||||
**Location**: `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/AlbumArtCache.kt`
|
|
||||||
|
|
||||||
Album art caching provides efficient bitmap storage for lock screen notifications with automatic LRU eviction and memory management.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
subgraph JellyTauPlayer["JellyTauPlayer.kt"]
|
|
||||||
LoadMedia["loadWithMetadata()<br/>- Store artworkUrl<br/>- Launch async download"]
|
|
||||||
AsyncDownload["Coroutine<br/>- Non-blocking<br/>- Dispatchers.IO"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Cache["AlbumArtCache.kt"]
|
|
||||||
MemoryCache["LruCache<String, Bitmap><br/>- 1/8 of heap<br/>- ~12-16MB typical<br/>- 50-100 albums capacity"]
|
|
||||||
Download["Download & Scale<br/>- 512x512 max<br/>- Exponential backoff"]
|
|
||||||
ErrorHandle["Error Handling<br/>- Graceful fallback<br/>- Auto-retry"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Service["JellyTauPlaybackService.kt"]
|
|
||||||
UpdateMeta["updateMediaMetadata()<br/>- Accept Bitmap parameter<br/>- Add METADATA_KEY_ALBUM_ART"]
|
|
||||||
Notification["Notification<br/>- setLargeIcon()<br/>- Lock screen display"]
|
|
||||||
end
|
|
||||||
|
|
||||||
LoadMedia --> AsyncDownload
|
|
||||||
AsyncDownload --> MemoryCache
|
|
||||||
MemoryCache --> Download
|
|
||||||
Download --> ErrorHandle
|
|
||||||
AsyncDownload --> UpdateMeta
|
|
||||||
UpdateMeta --> Notification
|
|
||||||
```
|
|
||||||
|
|
||||||
**AlbumArtCache Singleton:**
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
class AlbumArtCache(context: Context) {
|
|
||||||
private val memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
|
|
||||||
override fun sizeOf(key: String, bitmap: Bitmap): Int {
|
|
||||||
return bitmap.byteCount / 1024 // Size in KB
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getArtwork(url: String): Bitmap? {
|
|
||||||
memoryCache.get(url)?.let { return it }
|
|
||||||
return downloadAndCache(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun downloadAndCache(url: String): Bitmap? =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
// HTTP download with 5s timeout
|
|
||||||
// Scale to 512x512 max
|
|
||||||
// Auto-evict LRU if needed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Integration Flow:**
|
|
||||||
|
|
||||||
1. **Track Load** (`loadWithMetadata()`):
|
|
||||||
- Store artwork URL in `currentArtworkUrl`
|
|
||||||
- Reset bitmap to null
|
|
||||||
- Start playback immediately (non-blocking)
|
|
||||||
|
|
||||||
2. **Async Download** (Background Coroutine):
|
|
||||||
- Check cache: instant hit if available
|
|
||||||
- Network miss: download, scale, cache
|
|
||||||
- Auto-retry on network failure with exponential backoff
|
|
||||||
- Graceful fallback if artwork unavailable
|
|
||||||
|
|
||||||
3. **Notification Update**:
|
|
||||||
- Pass bitmap to `updatePlaybackServiceNotification()`
|
|
||||||
- Add to `MediaMetadataCompat` with `METADATA_KEY_ALBUM_ART`
|
|
||||||
- Display as large icon in notification
|
|
||||||
- Show on lock screen
|
|
||||||
|
|
||||||
**Memory Management:**
|
|
||||||
|
|
||||||
| Metric | Value |
|
|
||||||
|--------|-------|
|
|
||||||
| Cache Size | 1/8 of heap (12-16MB typical) |
|
|
||||||
| Max Resolution | 512x512 pixels |
|
|
||||||
| Capacity | ~50-100 album arts |
|
|
||||||
| Eviction Policy | LRU (Least Recently Used) |
|
|
||||||
| Lifetime | In-memory only (app session) |
|
|
||||||
| Network Timeout | 5 seconds per download |
|
|
||||||
|
|
||||||
**Performance Characteristics:**
|
|
||||||
|
|
||||||
- **Cache Hit**: ~1ms (in-memory retrieval)
|
|
||||||
- **Cache Miss**: ~200-500ms (download + scale)
|
|
||||||
- **Playback Impact**: Zero (async downloads)
|
|
||||||
- **Memory Overhead**: Max 16MB (auto-eviction)
|
|
||||||
- **Error Recovery**: Automatic with exponential backoff
|
|
||||||
|
|
||||||
## Backend Initialization
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/lib.rs`
|
|
||||||
|
|
||||||
Backend selection is platform-specific:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn create_player_backend(app_handle: tauri::AppHandle) -> Box<dyn PlayerBackend> {
|
|
||||||
let event_emitter = Arc::new(TauriEventEmitter::new(app_handle));
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
match MpvBackend::new(event_emitter.clone()) {
|
|
||||||
Ok(backend) => return Box::new(backend),
|
|
||||||
Err(e) => eprintln!("MPV init failed: {}", e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
{
|
|
||||||
// ExoPlayer requires Activity context, initialized separately
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback
|
|
||||||
Box::new(NullBackend::new())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
# Download Manager & Offline Architecture
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/download/`
|
|
||||||
|
|
||||||
The download manager provides offline media support with priority-based queue management, progress tracking, retry logic, and smart caching.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
subgraph Frontend["Frontend"]
|
|
||||||
DownloadButton["DownloadButton.svelte"]
|
|
||||||
DownloadsPage["/downloads"]
|
|
||||||
DownloadsStore["downloads.ts store"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Backend["Rust Backend"]
|
|
||||||
Commands["Download Commands"]
|
|
||||||
DownloadManager["DownloadManager"]
|
|
||||||
DownloadWorker["DownloadWorker"]
|
|
||||||
SmartCache["SmartCache Engine"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Storage["Storage"]
|
|
||||||
SQLite[("SQLite DB")]
|
|
||||||
MediaFiles[("Downloaded Files")]
|
|
||||||
end
|
|
||||||
|
|
||||||
DownloadButton -->|"invoke('download_item')"| Commands
|
|
||||||
DownloadsPage -->|"invoke('get_downloads')"| Commands
|
|
||||||
Commands --> DownloadManager
|
|
||||||
DownloadManager --> DownloadWorker
|
|
||||||
DownloadManager --> SmartCache
|
|
||||||
DownloadWorker -->|"HTTP Stream"| MediaFiles
|
|
||||||
DownloadWorker -->|"Events"| DownloadsStore
|
|
||||||
Commands <--> SQLite
|
|
||||||
SmartCache <--> SQLite
|
|
||||||
```
|
|
||||||
|
|
||||||
## Download Worker
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/download/worker.rs`
|
|
||||||
|
|
||||||
The download worker handles HTTP streaming with retry logic and resume support:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct DownloadWorker {
|
|
||||||
client: reqwest::Client,
|
|
||||||
max_retries: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct DownloadTask {
|
|
||||||
pub id: i64,
|
|
||||||
pub item_id: String,
|
|
||||||
pub user_id: String,
|
|
||||||
pub priority: i32,
|
|
||||||
pub url: String,
|
|
||||||
pub target_path: PathBuf,
|
|
||||||
pub mime_type: Option<String>,
|
|
||||||
pub expected_size: Option<i64>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Retry Strategy**:
|
|
||||||
- Exponential backoff: 5s, 15s, 45s
|
|
||||||
- Maximum 3 retry attempts
|
|
||||||
- HTTP Range requests for resume support
|
|
||||||
- Progress events emitted every 1MB
|
|
||||||
|
|
||||||
**Download Flow**:
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant UI
|
|
||||||
participant Command as download_item
|
|
||||||
participant DB as SQLite
|
|
||||||
participant Worker as DownloadWorker
|
|
||||||
participant Jellyfin as Jellyfin Server
|
|
||||||
participant Store as downloads store
|
|
||||||
|
|
||||||
UI->>Command: download_item(itemId, userId)
|
|
||||||
Command->>DB: INSERT INTO downloads
|
|
||||||
Command->>Worker: Start download task
|
|
||||||
Worker->>Jellyfin: GET /Items/{id}/Download
|
|
||||||
|
|
||||||
loop Progress Updates
|
|
||||||
Jellyfin->>Worker: Stream chunks
|
|
||||||
Worker->>Worker: Write to .part file
|
|
||||||
Worker->>Store: Emit progress event
|
|
||||||
Store->>UI: Update progress bar
|
|
||||||
end
|
|
||||||
|
|
||||||
Worker->>Worker: Rename .part to final
|
|
||||||
Worker->>DB: UPDATE status='completed'
|
|
||||||
Worker->>Store: Emit completed event
|
|
||||||
Store->>UI: Show completed
|
|
||||||
```
|
|
||||||
|
|
||||||
## Smart Caching Engine
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/download/cache.rs`
|
|
||||||
|
|
||||||
The smart caching system provides predictive downloads based on listening patterns:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct SmartCache {
|
|
||||||
config: Arc<Mutex<CacheConfig>>,
|
|
||||||
album_play_history: Arc<Mutex<HashMap<String, Vec<String>>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct CacheConfig {
|
|
||||||
pub queue_precache_enabled: bool,
|
|
||||||
pub queue_precache_count: usize, // Default: 5
|
|
||||||
pub album_affinity_enabled: bool,
|
|
||||||
pub album_affinity_threshold: usize, // Default: 3
|
|
||||||
pub storage_limit: u64, // Default: 10GB
|
|
||||||
pub wifi_only: bool, // Default: true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Caching Strategies**:
|
|
||||||
|
|
||||||
1. **Queue Pre-caching**: Auto-download next 5 tracks when playing (WiFi only)
|
|
||||||
2. **Album Affinity**: If user plays 3+ tracks from album, cache entire album
|
|
||||||
3. **LRU Eviction**: Remove least recently accessed when storage limit reached
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
Play["Track Played"] --> CheckQueue{"Queue<br/>Pre-cache?"}
|
|
||||||
CheckQueue -->|"Yes"| CacheNext5["Download<br/>Next 5 Tracks"]
|
|
||||||
|
|
||||||
Play --> TrackHistory["Track Play History"]
|
|
||||||
TrackHistory --> CheckAlbum{"3+ Tracks<br/>from Album?"}
|
|
||||||
CheckAlbum -->|"Yes"| CacheAlbum["Download<br/>Full Album"]
|
|
||||||
|
|
||||||
CacheNext5 --> CheckStorage{"Storage<br/>Limit?"}
|
|
||||||
CacheAlbum --> CheckStorage
|
|
||||||
CheckStorage -->|"Exceeded"| EvictLRU["Evict LRU Items"]
|
|
||||||
CheckStorage -->|"OK"| Download["Queue Download"]
|
|
||||||
```
|
|
||||||
|
|
||||||
## One Storage Model: Cache Entries Are Downloads
|
|
||||||
|
|
||||||
**TRACES**: UR-071 | DR-126, DR-127
|
|
||||||
|
|
||||||
A cache entry **is** a download with a shorter life: the same `downloads` row and
|
|
||||||
the same file handling, distinguished by `download_source` plus an expiry. There
|
|
||||||
is one storage model rather than a cache and a download library that can
|
|
||||||
disagree about what is on disk.
|
|
||||||
|
|
||||||
| `download_source` | Life | Reclaimed by |
|
|
||||||
|-------------------|------|--------------|
|
|
||||||
| `'auto'` (temporary) | Expiry, or eviction under space pressure | Both |
|
|
||||||
| `'user'` (permanent) | No expiry | Neither |
|
|
||||||
|
|
||||||
**Eviction only reclaims the temporary tier.** `evict_lru_async` originally
|
|
||||||
selected every completed download ordered by `completed_at ASC` with no source
|
|
||||||
filter, so hitting the storage limit deleted the *oldest* download — typically a
|
|
||||||
film saved deliberately for offline — to make room for a newly precached track.
|
|
||||||
It now evicts only `COALESCE(download_source, 'user') = 'auto'` rows.
|
|
||||||
`COALESCE` rather than a bare equality is load-bearing: rows predating the
|
|
||||||
migration can be NULL, and **unknown provenance must be treated as the user's,
|
|
||||||
never as disposable**. Freeing less than requested is the correct outcome when
|
|
||||||
only user downloads remain — the caller reports "unable to free enough".
|
|
||||||
|
|
||||||
A temporary row can be **promoted** to permanent when the user chooses to keep
|
|
||||||
it. That only clears the expiry and flips the source; the bytes never move.
|
|
||||||
|
|
||||||
## Offline Catalog Visibility
|
|
||||||
|
|
||||||
**TRACES**: UR-052 | DR-078, DR-079, DR-080
|
|
||||||
|
|
||||||
Offline, a library page shows **only media on the device**. A "Show all server
|
|
||||||
media" toggle additionally reveals the cached server catalog, greyed out and
|
|
||||||
queueable for download on reconnect.
|
|
||||||
|
|
||||||
The gate is a process-global `INCLUDE_CATALOG_BROWSE` in
|
|
||||||
`repository/offline.rs`, written by the `set_show_server_catalog` command. It
|
|
||||||
gates the synced-catalog leg of `get_items`; without it the toggle rendered but
|
|
||||||
every server item still appeared, which is the defect the spec was written for.
|
|
||||||
`isConnected` derives from backend-reported reachability alone (DR-079) — see
|
|
||||||
[07-connectivity.md](07-connectivity.md).
|
|
||||||
|
|
||||||
Per-item disk usage comes from `repository_get_download_disk_usage`
|
|
||||||
(`DownloadDiskUsage`), aggregated from `downloads.file_size` — used by the
|
|
||||||
Downloaded browse cards, detail pages, the device total and the remove
|
|
||||||
confirmation (DR-085).
|
|
||||||
|
|
||||||
## What a Video Download Fetches
|
|
||||||
|
|
||||||
**TRACES**: UR-071, UR-004 | DR-171, DR-293
|
|
||||||
|
|
||||||
An `original`-quality download is the server's untouched file (`Static=true`)
|
|
||||||
unless its audio cannot be decoded by **the renderer that will play it** —
|
|
||||||
`renderer_can_decode_audio`, DR-234's per-platform answer. Only then is the
|
|
||||||
server asked to re-encode the audio on the way down (`allowVideoStreamCopy`
|
|
||||||
keeps the picture byte-for-byte).
|
|
||||||
|
|
||||||
The distinction matters because a transcode is generated as it is sent: no
|
|
||||||
`Content-Length`, `Range` ignored. It measured ~1 MB/s and restarted from byte
|
|
||||||
zero on every network blip, against a direct copy that moved a 910 MB episode in
|
|
||||||
94 s with no retries. On Android the renderer is ExoPlayer with the FFmpeg
|
|
||||||
extension ([05-platform-backends.md](05-platform-backends.md)), which decodes
|
|
||||||
AC-3/E-AC-3/DTS/TrueHD, so Android downloads are always the direct copy. On
|
|
||||||
Linux the webview still renders video and the transcode still applies.
|
|
||||||
|
|
||||||
The policy used to judge against the *webview's* codec list on every platform
|
|
||||||
(DR-171), because a download outlives the native-video setting that was active
|
|
||||||
when it arrived. That reasoning is why Android's webview video path was removed
|
|
||||||
rather than merely defaulted off: a file downloaded as the original must never
|
|
||||||
meet a renderer that cannot decode it.
|
|
||||||
|
|
||||||
## Offline Means No Network
|
|
||||||
|
|
||||||
**TRACES**: UR-002, UR-071 | DR-294
|
|
||||||
|
|
||||||
Three defects made "offline" depend on the network; the invariants that replace
|
|
||||||
them:
|
|
||||||
|
|
||||||
- **A download plays without the server.** Playing a downloaded item asked the
|
|
||||||
server for its `PlaybackInfo` only to read the media-source id; offline that
|
|
||||||
retried for seven seconds, failed, and the file was never opened.
|
|
||||||
`OfflineRepository::local_playback_info` answers for any completed download of
|
|
||||||
the current user — local path, direct play, item id as media source (a download
|
|
||||||
names no source, so the server served its default, which carries the item's id)
|
|
||||||
— and `HybridRepository::get_playback_info` consults it **first**.
|
|
||||||
- **A slow cache read is waited for, never discarded.** The cache is one SQLite
|
|
||||||
connection behind one mutex, so any write in progress (the catalog sync that
|
|
||||||
starts at every launch, a download finishing) pushes a read past the 100 ms fast
|
|
||||||
path. `get_items`, the library list, genres and playlist items used to discard
|
|
||||||
such a read, wait for the server, and — offline — return its error over data on
|
|
||||||
disk; "More info" on a downloaded show failed that way. They now start the read
|
|
||||||
with `cache_try` (which keeps it running) and `settle` on it when the server
|
|
||||||
fails. Cache-only reads (search, favourites) have no server to fall back from,
|
|
||||||
so they simply await the cache.
|
|
||||||
- **Server-only sections degrade, they do not fail a page.** Next Up went only to
|
|
||||||
the server, and the TV landing page loads it in one `Promise.all`, so offline it
|
|
||||||
blanked the whole page. It now falls back to the cache when the server cannot
|
|
||||||
answer.
|
|
||||||
|
|
||||||
What still needs the server, deliberately: streaming anything not downloaded,
|
|
||||||
live TV and channels, reporting playback, and edits (favourites, playlists,
|
|
||||||
played state).
|
|
||||||
|
|
||||||
## Download Commands
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/commands/download/` — `mod.rs` (the commands below), `pinning.rs`, `smart_cache.rs`
|
|
||||||
|
|
||||||
| Command | Parameters | Description |
|
|
||||||
|---------|------------|-------------|
|
|
||||||
| `download_item` | `item_id, user_id, file_path` | Queue single item download |
|
|
||||||
| `download_album` | `album_id, user_id` | Queue all tracks in album |
|
|
||||||
| `get_downloads` | `user_id, status_filter` | Get download list |
|
|
||||||
| `pause_download` | `download_id` | Pause active download |
|
|
||||||
| `resume_download` | `download_id` | Resume paused download |
|
|
||||||
| `cancel_download` | `download_id` | Cancel and delete partial |
|
|
||||||
| `delete_download` | `download_id` | Delete completed download |
|
|
||||||
| `download_video` / `download_series` / `download_season` | item ids | Queue video content |
|
|
||||||
| `get_download_storage_stats` | `user_id` | Device totals for the downloads screen |
|
|
||||||
| `delete_album_downloads` / `delete_downloads_under` / `delete_all_downloads` | container id | Bulk removal |
|
|
||||||
| `pin_item` / `unpin_item` / `is_item_pinned` | `item_id` | Protect metadata from a cache clear |
|
|
||||||
| `set_max_concurrent_downloads` | `max` | Worker concurrency (3 by default) |
|
|
||||||
|
|
||||||
## Offline Commands
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/commands/offline.rs`
|
|
||||||
|
|
||||||
| Command | Parameters | Description |
|
|
||||||
|---------|------------|-------------|
|
|
||||||
| `offline_is_available` | `item_id` | Check if item downloaded |
|
|
||||||
| `offline_get_items` | `user_id` | Get all offline items |
|
|
||||||
| `offline_search` | `user_id, query` | Search downloaded items |
|
|
||||||
|
|
||||||
## Player Integration
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/commands/player.rs` (modified)
|
|
||||||
|
|
||||||
The player checks for local downloads before streaming:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn create_media_item(req: PlayItemRequest, db: Option<&DatabaseWrapper>) -> MediaItem {
|
|
||||||
let local_path = db.and_then(|db_wrapper| {
|
|
||||||
check_for_local_download(db_wrapper, &jellyfin_id).ok().flatten()
|
|
||||||
});
|
|
||||||
|
|
||||||
let source = if let Some(path) = local_path {
|
|
||||||
MediaSource::Local {
|
|
||||||
file_path: PathBuf::from(path),
|
|
||||||
jellyfin_item_id: Some(jellyfin_id.clone())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
MediaSource::Remote {
|
|
||||||
stream_url: req.stream_url,
|
|
||||||
jellyfin_item_id: jellyfin_id.clone()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
MediaItem { source, /* ... */ }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Frontend Downloads Store
|
|
||||||
|
|
||||||
**Location**: `src/lib/stores/downloads.ts`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface DownloadsState {
|
|
||||||
downloads: Record<number, DownloadInfo>;
|
|
||||||
activeCount: number;
|
|
||||||
queuedCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const downloads = createDownloadsStore();
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
downloads.downloadItem(itemId, userId, filePath)
|
|
||||||
downloads.downloadAlbum(albumId, userId)
|
|
||||||
downloads.pause(downloadId)
|
|
||||||
downloads.resume(downloadId)
|
|
||||||
downloads.cancel(downloadId)
|
|
||||||
downloads.delete(downloadId)
|
|
||||||
downloads.refresh(userId, statusFilter)
|
|
||||||
|
|
||||||
// Derived stores
|
|
||||||
export const activeDownloads = derived(downloads, ($d) =>
|
|
||||||
Object.values($d.downloads).filter((d) => d.status === 'downloading')
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Event Handling**:
|
|
||||||
|
|
||||||
The store listens to Tauri events for real-time updates:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
listen<DownloadEvent>('download-event', (event) => {
|
|
||||||
const payload = event.payload;
|
|
||||||
|
|
||||||
switch (payload.type) {
|
|
||||||
case 'started':
|
|
||||||
// Update status to 'downloading'
|
|
||||||
case 'progress':
|
|
||||||
// Update progress and bytes_downloaded
|
|
||||||
case 'completed':
|
|
||||||
// Update status to 'completed', progress to 1.0
|
|
||||||
case 'failed':
|
|
||||||
// Update status to 'failed', store error message
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Download UI Components
|
|
||||||
|
|
||||||
**DownloadButton** (`src/lib/components/library/DownloadButton.svelte`):
|
|
||||||
- Multiple states: available, downloading, completed, failed, paused
|
|
||||||
- Circular progress ring during download
|
|
||||||
- Size variants: sm, md, lg
|
|
||||||
- Integrated into TrackList with `showDownload={true}` prop
|
|
||||||
|
|
||||||
**DownloadItem** (`src/lib/components/downloads/DownloadItem.svelte`):
|
|
||||||
- Individual download list item with progress bar
|
|
||||||
- Action buttons: pause, resume, cancel, delete
|
|
||||||
- Status indicators with color coding
|
|
||||||
|
|
||||||
**Downloads Page** (`src/routes/downloads/+page.svelte`):
|
|
||||||
- Active/Completed tabs
|
|
||||||
- Bulk actions: Pause All, Resume All, Clear Completed
|
|
||||||
- Empty states with helpful instructions
|
|
||||||
|
|
||||||
## Database Schema
|
|
||||||
|
|
||||||
**downloads table**:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE downloads (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
item_id TEXT NOT NULL,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
file_path TEXT,
|
|
||||||
file_size INTEGER,
|
|
||||||
mime_type TEXT,
|
|
||||||
status TEXT DEFAULT 'pending', -- pending, downloading, completed, failed, paused
|
|
||||||
progress REAL DEFAULT 0.0,
|
|
||||||
bytes_downloaded INTEGER DEFAULT 0,
|
|
||||||
priority INTEGER DEFAULT 0,
|
|
||||||
error_message TEXT,
|
|
||||||
retry_count INTEGER DEFAULT 0,
|
|
||||||
queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
started_at TEXT,
|
|
||||||
completed_at TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_downloads_queue
|
|
||||||
ON downloads(status, priority DESC, queued_at ASC)
|
|
||||||
WHERE status IN ('pending', 'downloading');
|
|
||||||
```
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
# Connectivity & Network Architecture
|
|
||||||
|
|
||||||
## HTTP Client with Retry Logic
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/jellyfin/http_client.rs`
|
|
||||||
|
|
||||||
The HTTP client provides automatic retry with exponential backoff for network resilience:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct HttpClient {
|
|
||||||
client: reqwest::Client,
|
|
||||||
config: HttpConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct HttpConfig {
|
|
||||||
pub timeout: Duration, // Default: 30s (large library queries can be slow)
|
|
||||||
pub max_retries: u32, // Default: 3
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> Note: ordinary requests use the 30s timeout above. The connectivity recovery probe (`ping`) uses a shorter, dedicated 5s timeout so an unreachable server is detected quickly while offline.
|
|
||||||
|
|
||||||
**Retry Strategy:**
|
|
||||||
- Retry delays: 1s, 2s, 4s (exponential backoff)
|
|
||||||
- Retries on: Network errors, 5xx server errors
|
|
||||||
- No retry on: 4xx client errors, 401/403 authentication errors
|
|
||||||
|
|
||||||
**Error Classification:**
|
|
||||||
```rust
|
|
||||||
pub enum ErrorKind {
|
|
||||||
Network, // Connection failures, timeouts, DNS errors
|
|
||||||
Authentication, // 401/403 responses
|
|
||||||
Server, // 5xx server errors
|
|
||||||
Client, // Other 4xx errors
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Connectivity Monitor
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/connectivity/mod.rs`
|
|
||||||
|
|
||||||
The connectivity monitor is the **single source of truth** for server reachability. Its primary signal is the outcome of *real repository traffic* — every server request the user actually makes. A standalone `/System/Info/Public` probe is kept only as an offline recovery detector.
|
|
||||||
|
|
||||||
### Source of truth: repository traffic
|
|
||||||
|
|
||||||
`OnlineRepository` reports the result of each server request to the monitor, classified via `RepoError`:
|
|
||||||
|
|
||||||
| Repository outcome | Meaning | Effect on reachability |
|
|
||||||
|--------------------|---------|------------------------|
|
|
||||||
| `Ok(_)` | Server answered successfully | Mark **reachable** (instant recovery) |
|
|
||||||
| `Err(Authentication)` | Server answered with 401/403 | Mark **reachable** (server is up; request was rejected) |
|
|
||||||
| `Err(NotFound)` | Server answered with 404 | Mark **reachable** (server is up) |
|
|
||||||
| `Err(Server)` | Server answered with 5xx / bad body | Mark **reachable** (server is up) |
|
|
||||||
| `Err(Network)` | Connection failure / timeout / DNS | **Candidate for offline** (see debounce) |
|
|
||||||
| `Err(Database)` | Local cache error only | No effect (not a server signal) |
|
|
||||||
|
|
||||||
This classification fixes the previous bug where a successful `/System/Info/Public` ping reported "online" even while the user's authenticated data calls were failing — and vice versa.
|
|
||||||
|
|
||||||
### Time-window debounce (offline) + instant recovery (online)
|
|
||||||
|
|
||||||
To stop the banner from flapping on a single dropped request, the transition to **offline** is debounced over a time window:
|
|
||||||
|
|
||||||
- On the **first** `Network` failure, the monitor records `first_failure_at`.
|
|
||||||
- It flips `is_server_reachable = false` only once `Network` failures have persisted continuously for `OFFLINE_CONFIRM_WINDOW` (5s) with no intervening success.
|
|
||||||
- **Any** success (or server-answered error) clears `first_failure_at` and immediately marks reachable.
|
|
||||||
|
|
||||||
Recovery is therefore instant and asymmetric: one good response brings the app back online, but a brief blip never trips the banner.
|
|
||||||
|
|
||||||
### Offline-only recovery probe
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
Repo["OnlineRepository"] -->|"success / RepoError"| Monitor["ConnectivityMonitor"]
|
|
||||||
Monitor --> State{"is_server_reachable?"}
|
|
||||||
State -->|"Online"| NoProbe["No background polling<br/>(real traffic is the signal)"]
|
|
||||||
State -->|"Offline"| Probe["5s /System/Info/Public probe<br/>(recovery detector)"]
|
|
||||||
Probe -->|"reachable again"| Monitor
|
|
||||||
Monitor -->|"on change"| Emit["Emit connectivity:changed<br/>+ connectivity:reconnected"]
|
|
||||||
Emit --> Frontend["Frontend Store → banner"]
|
|
||||||
```
|
|
||||||
|
|
||||||
While **online**, there is no background polling — real requests keep the state fresh. While **offline**, the fast 5s probe runs so an idle app still detects the server returning even when no user traffic is flowing.
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- **Traffic-driven**: Reachability follows the requests the user actually makes.
|
|
||||||
- **Time-window debounce**: Offline declared only after `OFFLINE_CONFIRM_WINDOW` (5s) of sustained network failure; recovery is instant.
|
|
||||||
- **Offline-only probe**: 5s `/System/Info/Public` probe runs only while offline.
|
|
||||||
- **Event Emission**: Emits `connectivity:changed` and `connectivity:reconnected` events.
|
|
||||||
- **Thread-Safe**: Uses `Arc<RwLock<>>` for shared state.
|
|
||||||
|
|
||||||
**Tauri Commands:**
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `connectivity_check_server` | Manual reachability check (also used by the frontend's advisory `navigator.onLine` hint) |
|
|
||||||
| `connectivity_set_server_url` | Update monitored server URL |
|
|
||||||
| `connectivity_get_status` | Get current connectivity status |
|
|
||||||
| `connectivity_start_monitoring` | Start the offline recovery probe |
|
|
||||||
| `connectivity_stop_monitoring` | Stop the probe |
|
|
||||||
| `connectivity_mark_reachable` | Mark reachable — driven by `OnlineRepository` on every server success |
|
|
||||||
| `connectivity_mark_unreachable` | Mark unreachable — driven by `OnlineRepository` on `RepoError::Network` (subject to debounce) |
|
|
||||||
|
|
||||||
**Frontend Integration:**
|
|
||||||
```typescript
|
|
||||||
// The store is a pure reflection of backend events — it no longer decides
|
|
||||||
// reachability itself. navigator.onLine is advisory: it triggers an immediate
|
|
||||||
// recheck rather than forcing the offline state.
|
|
||||||
listen<{ isReachable: boolean }>("connectivity:changed", (event) => {
|
|
||||||
updateConnectivityState(event.payload.isReachable);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Network Resilience Architecture
|
|
||||||
|
|
||||||
The connectivity system provides resilience through multiple layers:
|
|
||||||
|
|
||||||
1. **HTTP Client Layer**: Automatic retry with exponential backoff
|
|
||||||
2. **Connectivity Monitoring**: Reachability derived from real repository traffic, with an offline-only recovery probe
|
|
||||||
3. **Frontend Integration**: Offline mode detection and UI updates (a pure reflection of backend events)
|
|
||||||
4. **Sync Queue**: Offline mutations queued for later (see [06-downloads-and-offline.md](06-downloads-and-offline.md))
|
|
||||||
|
|
||||||
**Design Principles:**
|
|
||||||
- **Single source of truth**: Reachability follows the outcome of real requests, classified via `RepoError`; the frontend store and the probe never compete to decide it.
|
|
||||||
- **Fail Fast**: Don't retry 4xx errors (client errors, authentication).
|
|
||||||
- **Fail Slow**: Retry network and 5xx errors with increasing delays.
|
|
||||||
- **Debounced offline, instant online**: Declare offline only after a sustained failure window; recover on the first success.
|
|
||||||
- **Probe only when needed**: Background polling runs only while offline, as a recovery detector.
|
|
||||||
- **Event-Driven**: Frontend reacts to connectivity changes via events.
|
|
||||||
@@ -1,614 +0,0 @@
|
|||||||
# Offline Database Design
|
|
||||||
|
|
||||||
## Entity Relationship Diagram
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
erDiagram
|
|
||||||
servers ||--o{ users : "has"
|
|
||||||
servers ||--o{ libraries : "has"
|
|
||||||
libraries ||--o{ items : "contains"
|
|
||||||
items ||--o{ items : "parent_of"
|
|
||||||
items ||--o{ user_data : "has"
|
|
||||||
items ||--o{ downloads : "has"
|
|
||||||
items ||--o{ media_streams : "has"
|
|
||||||
items ||--o{ thumbnails : "has"
|
|
||||||
users ||--o{ user_data : "owns"
|
|
||||||
users ||--o{ downloads : "owns"
|
|
||||||
users ||--o{ sync_queue : "owns"
|
|
||||||
|
|
||||||
servers {
|
|
||||||
int id PK
|
|
||||||
string jellyfin_id UK
|
|
||||||
string name
|
|
||||||
string url
|
|
||||||
string version
|
|
||||||
datetime last_sync
|
|
||||||
}
|
|
||||||
|
|
||||||
users {
|
|
||||||
int id PK
|
|
||||||
string jellyfin_id
|
|
||||||
int server_id FK
|
|
||||||
string name
|
|
||||||
boolean is_active
|
|
||||||
}
|
|
||||||
|
|
||||||
libraries {
|
|
||||||
int id PK
|
|
||||||
string jellyfin_id
|
|
||||||
int server_id FK
|
|
||||||
string name
|
|
||||||
string collection_type
|
|
||||||
string image_tag
|
|
||||||
}
|
|
||||||
|
|
||||||
items {
|
|
||||||
int id PK
|
|
||||||
string jellyfin_id
|
|
||||||
int server_id FK
|
|
||||||
int library_id FK
|
|
||||||
int parent_id FK
|
|
||||||
string type
|
|
||||||
string name
|
|
||||||
string sort_name
|
|
||||||
string overview
|
|
||||||
int production_year
|
|
||||||
float community_rating
|
|
||||||
string official_rating
|
|
||||||
int runtime_ticks
|
|
||||||
string primary_image_tag
|
|
||||||
string backdrop_image_tag
|
|
||||||
string album_id
|
|
||||||
string album_name
|
|
||||||
string album_artist
|
|
||||||
json artists
|
|
||||||
json genres
|
|
||||||
int index_number
|
|
||||||
int parent_index_number
|
|
||||||
string premiere_date
|
|
||||||
json metadata_json
|
|
||||||
datetime created_at
|
|
||||||
datetime updated_at
|
|
||||||
datetime last_sync
|
|
||||||
}
|
|
||||||
|
|
||||||
user_data {
|
|
||||||
int id PK
|
|
||||||
int item_id FK
|
|
||||||
int user_id FK
|
|
||||||
int position_ticks
|
|
||||||
int play_count
|
|
||||||
boolean is_favorite
|
|
||||||
boolean played
|
|
||||||
datetime last_played
|
|
||||||
datetime updated_at
|
|
||||||
datetime synced_at
|
|
||||||
}
|
|
||||||
|
|
||||||
downloads {
|
|
||||||
int id PK
|
|
||||||
int item_id FK
|
|
||||||
int user_id FK
|
|
||||||
string file_path
|
|
||||||
int file_size
|
|
||||||
string status
|
|
||||||
float progress
|
|
||||||
int priority
|
|
||||||
string error_message
|
|
||||||
datetime created_at
|
|
||||||
datetime completed_at
|
|
||||||
}
|
|
||||||
|
|
||||||
media_streams {
|
|
||||||
int id PK
|
|
||||||
int item_id FK
|
|
||||||
int stream_index
|
|
||||||
string type
|
|
||||||
string codec
|
|
||||||
string language
|
|
||||||
string display_title
|
|
||||||
boolean is_default
|
|
||||||
boolean is_forced
|
|
||||||
boolean is_external
|
|
||||||
}
|
|
||||||
|
|
||||||
sync_queue {
|
|
||||||
int id PK
|
|
||||||
int user_id FK
|
|
||||||
string operation
|
|
||||||
string entity_type
|
|
||||||
string entity_id
|
|
||||||
json payload
|
|
||||||
datetime created_at
|
|
||||||
int attempts
|
|
||||||
datetime last_attempt
|
|
||||||
string status
|
|
||||||
}
|
|
||||||
|
|
||||||
thumbnails {
|
|
||||||
int id PK
|
|
||||||
int item_id FK
|
|
||||||
string image_type
|
|
||||||
string image_tag
|
|
||||||
string file_path
|
|
||||||
int width
|
|
||||||
int height
|
|
||||||
datetime cached_at
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Table Definitions
|
|
||||||
|
|
||||||
### servers
|
|
||||||
Stores connected Jellyfin server information.
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE servers (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
jellyfin_id TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
url TEXT NOT NULL,
|
|
||||||
version TEXT,
|
|
||||||
last_sync DATETIME,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### users
|
|
||||||
Stores user accounts per server. Access tokens are stored separately in secure storage (see [09-security.md](09-security.md)).
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE users (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
jellyfin_id TEXT NOT NULL,
|
|
||||||
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
is_active BOOLEAN DEFAULT 0,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(jellyfin_id, server_id)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### libraries
|
|
||||||
Stores library/collection metadata.
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE libraries (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
jellyfin_id TEXT NOT NULL,
|
|
||||||
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
collection_type TEXT,
|
|
||||||
image_tag TEXT,
|
|
||||||
sort_order INTEGER DEFAULT 0,
|
|
||||||
last_sync DATETIME,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(jellyfin_id, server_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_libraries_server ON libraries(server_id);
|
|
||||||
```
|
|
||||||
|
|
||||||
### items
|
|
||||||
Main table for all media items (movies, episodes, albums, songs, etc.).
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE items (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
jellyfin_id TEXT NOT NULL,
|
|
||||||
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
|
||||||
library_id INTEGER REFERENCES libraries(id) ON DELETE SET NULL,
|
|
||||||
parent_id INTEGER REFERENCES items(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Basic metadata
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
sort_name TEXT,
|
|
||||||
overview TEXT,
|
|
||||||
|
|
||||||
-- Media info
|
|
||||||
production_year INTEGER,
|
|
||||||
community_rating REAL,
|
|
||||||
official_rating TEXT,
|
|
||||||
runtime_ticks INTEGER,
|
|
||||||
|
|
||||||
-- Images
|
|
||||||
primary_image_tag TEXT,
|
|
||||||
backdrop_image_tag TEXT,
|
|
||||||
|
|
||||||
-- Audio-specific
|
|
||||||
album_id TEXT,
|
|
||||||
album_name TEXT,
|
|
||||||
album_artist TEXT,
|
|
||||||
artists TEXT, -- JSON array
|
|
||||||
|
|
||||||
-- Series/Season-specific
|
|
||||||
index_number INTEGER,
|
|
||||||
parent_index_number INTEGER,
|
|
||||||
series_id TEXT,
|
|
||||||
series_name TEXT,
|
|
||||||
season_id TEXT,
|
|
||||||
|
|
||||||
-- Additional
|
|
||||||
genres TEXT, -- JSON array
|
|
||||||
premiere_date TEXT,
|
|
||||||
metadata_json TEXT,
|
|
||||||
|
|
||||||
-- Sync tracking
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_sync DATETIME,
|
|
||||||
|
|
||||||
UNIQUE(jellyfin_id, server_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Performance indexes
|
|
||||||
CREATE INDEX idx_items_server ON items(server_id);
|
|
||||||
CREATE INDEX idx_items_library ON items(library_id);
|
|
||||||
CREATE INDEX idx_items_parent ON items(parent_id);
|
|
||||||
CREATE INDEX idx_items_type ON items(type);
|
|
||||||
CREATE INDEX idx_items_album ON items(album_id);
|
|
||||||
CREATE INDEX idx_items_series ON items(series_id);
|
|
||||||
CREATE INDEX idx_items_name ON items(name COLLATE NOCASE);
|
|
||||||
|
|
||||||
-- Full-text search
|
|
||||||
CREATE VIRTUAL TABLE items_fts USING fts5(
|
|
||||||
name,
|
|
||||||
overview,
|
|
||||||
artists,
|
|
||||||
album_name,
|
|
||||||
album_artist,
|
|
||||||
content='items',
|
|
||||||
content_rowid='id'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Triggers to keep FTS in sync
|
|
||||||
CREATE TRIGGER items_ai AFTER INSERT ON items BEGIN
|
|
||||||
INSERT INTO items_fts(rowid, name, overview, artists, album_name, album_artist)
|
|
||||||
VALUES (new.id, new.name, new.overview, new.artists, new.album_name, new.album_artist);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER items_ad AFTER DELETE ON items BEGIN
|
|
||||||
INSERT INTO items_fts(items_fts, rowid, name, overview, artists, album_name, album_artist)
|
|
||||||
VALUES ('delete', old.id, old.name, old.overview, old.artists, old.album_name, old.album_artist);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER items_au AFTER UPDATE ON items BEGIN
|
|
||||||
INSERT INTO items_fts(items_fts, rowid, name, overview, artists, album_name, album_artist)
|
|
||||||
VALUES ('delete', old.id, old.name, old.overview, old.artists, old.album_name, old.album_artist);
|
|
||||||
INSERT INTO items_fts(rowid, name, overview, artists, album_name, album_artist)
|
|
||||||
VALUES (new.id, new.name, new.overview, new.artists, new.album_name, new.album_artist);
|
|
||||||
END;
|
|
||||||
```
|
|
||||||
|
|
||||||
### media_streams
|
|
||||||
Stores subtitle and audio track information for items.
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE media_streams (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
|
||||||
stream_index INTEGER NOT NULL,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
codec TEXT,
|
|
||||||
language TEXT,
|
|
||||||
display_title TEXT,
|
|
||||||
is_default BOOLEAN DEFAULT 0,
|
|
||||||
is_forced BOOLEAN DEFAULT 0,
|
|
||||||
is_external BOOLEAN DEFAULT 0,
|
|
||||||
path TEXT,
|
|
||||||
UNIQUE(item_id, stream_index)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_media_streams_item ON media_streams(item_id);
|
|
||||||
```
|
|
||||||
|
|
||||||
### user_data
|
|
||||||
Stores per-user data for items (favorites, progress, play count).
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE user_data (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Playback state
|
|
||||||
position_ticks INTEGER DEFAULT 0,
|
|
||||||
play_count INTEGER DEFAULT 0,
|
|
||||||
played BOOLEAN DEFAULT 0,
|
|
||||||
last_played DATETIME,
|
|
||||||
|
|
||||||
-- User preferences
|
|
||||||
is_favorite BOOLEAN DEFAULT 0,
|
|
||||||
user_rating REAL,
|
|
||||||
|
|
||||||
-- Sync tracking
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
synced_at DATETIME,
|
|
||||||
needs_sync BOOLEAN DEFAULT 0,
|
|
||||||
|
|
||||||
UNIQUE(item_id, user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_user_data_item ON user_data(item_id);
|
|
||||||
CREATE INDEX idx_user_data_user ON user_data(user_id);
|
|
||||||
CREATE INDEX idx_user_data_needs_sync ON user_data(needs_sync) WHERE needs_sync = 1;
|
|
||||||
CREATE INDEX idx_user_data_favorites ON user_data(user_id, is_favorite) WHERE is_favorite = 1;
|
|
||||||
CREATE INDEX idx_user_data_in_progress ON user_data(user_id, position_ticks)
|
|
||||||
WHERE position_ticks > 0 AND played = 0;
|
|
||||||
```
|
|
||||||
|
|
||||||
### downloads
|
|
||||||
Tracks downloaded media files.
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE downloads (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
file_path TEXT,
|
|
||||||
file_size INTEGER,
|
|
||||||
file_hash TEXT,
|
|
||||||
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
progress REAL DEFAULT 0,
|
|
||||||
bytes_downloaded INTEGER DEFAULT 0,
|
|
||||||
|
|
||||||
transcode_profile TEXT,
|
|
||||||
|
|
||||||
priority INTEGER DEFAULT 0,
|
|
||||||
error_message TEXT,
|
|
||||||
retry_count INTEGER DEFAULT 0,
|
|
||||||
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
started_at DATETIME,
|
|
||||||
completed_at DATETIME,
|
|
||||||
expires_at DATETIME,
|
|
||||||
|
|
||||||
UNIQUE(item_id, user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_downloads_status ON downloads(status);
|
|
||||||
CREATE INDEX idx_downloads_user ON downloads(user_id);
|
|
||||||
CREATE INDEX idx_downloads_queue ON downloads(status, priority DESC, created_at ASC)
|
|
||||||
WHERE status IN ('pending', 'downloading');
|
|
||||||
```
|
|
||||||
|
|
||||||
### sync_queue
|
|
||||||
Stores mutations to sync back to server when online.
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE sync_queue (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
operation TEXT NOT NULL,
|
|
||||||
entity_type TEXT NOT NULL,
|
|
||||||
entity_id TEXT NOT NULL,
|
|
||||||
payload TEXT,
|
|
||||||
|
|
||||||
status TEXT DEFAULT 'pending',
|
|
||||||
attempts INTEGER DEFAULT 0,
|
|
||||||
max_attempts INTEGER DEFAULT 5,
|
|
||||||
last_attempt DATETIME,
|
|
||||||
error_message TEXT,
|
|
||||||
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
completed_at DATETIME
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_sync_queue_status ON sync_queue(status, created_at ASC)
|
|
||||||
WHERE status = 'pending';
|
|
||||||
CREATE INDEX idx_sync_queue_user ON sync_queue(user_id);
|
|
||||||
```
|
|
||||||
|
|
||||||
### thumbnails
|
|
||||||
Caches downloaded artwork.
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE thumbnails (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
|
||||||
image_type TEXT NOT NULL,
|
|
||||||
image_tag TEXT,
|
|
||||||
file_path TEXT NOT NULL,
|
|
||||||
width INTEGER,
|
|
||||||
height INTEGER,
|
|
||||||
file_size INTEGER,
|
|
||||||
cached_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_accessed DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(item_id, image_type, width)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_thumbnails_item ON thumbnails(item_id);
|
|
||||||
CREATE INDEX idx_thumbnails_lru ON thumbnails(last_accessed ASC);
|
|
||||||
```
|
|
||||||
|
|
||||||
### playlists (for local/synced playlists)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE playlists (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
jellyfin_id TEXT,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
is_local_only BOOLEAN DEFAULT 0,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
synced_at DATETIME,
|
|
||||||
needs_sync BOOLEAN DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE playlist_items (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
|
||||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
|
||||||
sort_order INTEGER NOT NULL,
|
|
||||||
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(playlist_id, item_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Queries
|
|
||||||
|
|
||||||
### Get items for offline library browsing
|
|
||||||
```sql
|
|
||||||
-- Get all albums in a music library
|
|
||||||
SELECT * FROM items
|
|
||||||
WHERE library_id = ? AND type = 'MusicAlbum'
|
|
||||||
ORDER BY sort_name;
|
|
||||||
|
|
||||||
-- Get tracks for an album
|
|
||||||
SELECT * FROM items
|
|
||||||
WHERE album_id = ? AND type = 'Audio'
|
|
||||||
ORDER BY parent_index_number, index_number;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Resume / Continue Watching
|
|
||||||
```sql
|
|
||||||
SELECT i.*, ud.position_ticks, ud.last_played
|
|
||||||
FROM items i
|
|
||||||
JOIN user_data ud ON ud.item_id = i.id
|
|
||||||
WHERE ud.user_id = ?
|
|
||||||
AND ud.position_ticks > 0
|
|
||||||
AND ud.played = 0
|
|
||||||
ORDER BY ud.last_played DESC
|
|
||||||
LIMIT 20;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Offline search
|
|
||||||
```sql
|
|
||||||
SELECT i.* FROM items i
|
|
||||||
JOIN items_fts fts ON fts.rowid = i.id
|
|
||||||
WHERE items_fts MATCH ?
|
|
||||||
ORDER BY rank;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Download queue management
|
|
||||||
```sql
|
|
||||||
-- Get next item to download
|
|
||||||
SELECT d.*, i.name, i.type
|
|
||||||
FROM downloads d
|
|
||||||
JOIN items i ON i.id = d.item_id
|
|
||||||
WHERE d.status = 'pending'
|
|
||||||
ORDER BY d.priority DESC, d.created_at ASC
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
-- Get download progress for UI
|
|
||||||
SELECT
|
|
||||||
d.status,
|
|
||||||
COUNT(*) as count,
|
|
||||||
SUM(d.file_size) as total_size,
|
|
||||||
SUM(d.bytes_downloaded) as downloaded
|
|
||||||
FROM downloads d
|
|
||||||
WHERE d.user_id = ?
|
|
||||||
GROUP BY d.status;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sync queue processing
|
|
||||||
```sql
|
|
||||||
-- Get pending sync operations (oldest first)
|
|
||||||
SELECT * FROM sync_queue
|
|
||||||
WHERE status = 'pending'
|
|
||||||
AND attempts < max_attempts
|
|
||||||
ORDER BY created_at ASC
|
|
||||||
LIMIT 10;
|
|
||||||
|
|
||||||
-- Mark operation complete
|
|
||||||
UPDATE sync_queue
|
|
||||||
SET status = 'completed', completed_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
### Online Mode
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
subgraph OnlineMode["Online Mode"]
|
|
||||||
JellyfinServer["Jellyfin Server"]
|
|
||||||
OnlineRepo["OnlineRepo"]
|
|
||||||
SQLite["SQLite"]
|
|
||||||
HybridRepo["HybridRepository"]
|
|
||||||
UI["UI / Stores"]
|
|
||||||
|
|
||||||
JellyfinServer -->|"API Response"| OnlineRepo
|
|
||||||
OnlineRepo -->|"Cache"| SQLite
|
|
||||||
SQLite -->|"Sync"| JellyfinServer
|
|
||||||
OnlineRepo -->|"Response"| HybridRepo
|
|
||||||
SQLite -->|"Fallback"| HybridRepo
|
|
||||||
HybridRepo --> UI
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### Offline Mode
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
subgraph OfflineMode["Offline Mode"]
|
|
||||||
OfflineRepo["OfflineRepo"]
|
|
||||||
SQLite2["SQLite"]
|
|
||||||
SyncQueue["sync_queue<br/>(Queued for later)"]
|
|
||||||
HybridRepo2["HybridRepository"]
|
|
||||||
UI2["UI / Stores"]
|
|
||||||
|
|
||||||
OfflineRepo <-->|"Query"| SQLite2
|
|
||||||
SQLite2 -->|"Mutations"| SyncQueue
|
|
||||||
OfflineRepo --> HybridRepo2
|
|
||||||
HybridRepo2 --> UI2
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sync on Reconnect
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
NetworkRestored["Network restored"]
|
|
||||||
SyncService["SyncService"]
|
|
||||||
SyncQueue2["sync_queue"]
|
|
||||||
JellyfinAPI["Jellyfin API"]
|
|
||||||
MarkSynced["Mark synced"]
|
|
||||||
|
|
||||||
NetworkRestored --> SyncService
|
|
||||||
SyncService -->|"Read"| SyncQueue2
|
|
||||||
SyncQueue2 -->|"Send"| JellyfinAPI
|
|
||||||
JellyfinAPI -->|"Success"| MarkSynced
|
|
||||||
MarkSynced --> SyncService
|
|
||||||
```
|
|
||||||
|
|
||||||
## Storage Estimates
|
|
||||||
|
|
||||||
| Content Type | Metadata Size | Thumbnail Size | Media Size |
|
|
||||||
|--------------|---------------|----------------|------------|
|
|
||||||
| Song | ~2 KB | ~50 KB (300px) | 5-15 MB |
|
|
||||||
| Album (12 tracks) | ~30 KB | ~100 KB | 60-180 MB |
|
|
||||||
| Movie | ~5 KB | ~200 KB | 1-8 GB |
|
|
||||||
| Episode | ~3 KB | ~100 KB | 300 MB - 2 GB |
|
|
||||||
| Full music library (5000 songs) | ~10 MB | ~250 MB | 25-75 GB |
|
|
||||||
|
|
||||||
## Rust Module Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src-tauri/src/storage/
|
|
||||||
├── mod.rs # Module exports, Database struct
|
|
||||||
├── schema.rs # Table definitions, migrations
|
|
||||||
├── models.rs # Rust structs matching tables
|
|
||||||
├── queries/
|
|
||||||
│ ├── mod.rs
|
|
||||||
│ ├── items.rs # Item CRUD operations
|
|
||||||
│ ├── user_data.rs # User data operations
|
|
||||||
│ ├── downloads.rs # Download queue operations
|
|
||||||
│ └── sync.rs # Sync queue operations
|
|
||||||
└── sync/
|
|
||||||
├── mod.rs # SyncService
|
|
||||||
├── manager.rs # Background sync manager
|
|
||||||
└── operations.rs # Individual sync operation handlers
|
|
||||||
```
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
# Security
|
|
||||||
|
|
||||||
## Authentication Token Storage
|
|
||||||
|
|
||||||
Access tokens are **not** stored in the SQLite database. Instead, they are stored using platform-native secure storage:
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
LoginSuccess["Login Success"]
|
|
||||||
KeyringCheck{"System Keyring<br/>Available?"}
|
|
||||||
OSCredential["Store in OS Credential Manager<br/>- Linux: libsecret/GNOME Keyring<br/>- macOS: Keychain<br/>- Windows: Credential Manager<br/>- Android: EncryptedSharedPrefs"]
|
|
||||||
EncryptedFallback["Encrypted File Fallback<br/>(AES-256-GCM)"]
|
|
||||||
|
|
||||||
LoginSuccess --> KeyringCheck
|
|
||||||
KeyringCheck -->|"Yes"| OSCredential
|
|
||||||
KeyringCheck -->|"No"| EncryptedFallback
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Format:**
|
|
||||||
```
|
|
||||||
jellytau::{server_id}::{user_id}::access_token
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rationale:**
|
|
||||||
- Tokens in SQLite would be readable if the database file is accessed
|
|
||||||
- System keyrings provide OS-level encryption and access control
|
|
||||||
- Fallback ensures functionality on minimal systems without a keyring daemon
|
|
||||||
|
|
||||||
## Secure Storage Module
|
|
||||||
|
|
||||||
**Location**: `src-tauri/src/secure_storage/` (planned)
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub trait SecureStorage: Send + Sync {
|
|
||||||
fn store(&self, key: &str, value: &str) -> Result<(), SecureStorageError>;
|
|
||||||
fn retrieve(&self, key: &str) -> Result<Option<String>, SecureStorageError>;
|
|
||||||
fn delete(&self, key: &str) -> Result<(), SecureStorageError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Platform implementations
|
|
||||||
pub struct KeyringStorage; // Uses keyring crate
|
|
||||||
pub struct EncryptedFileStorage; // AES-256-GCM fallback
|
|
||||||
```
|
|
||||||
|
|
||||||
## Network Security
|
|
||||||
|
|
||||||
| Aspect | Implementation |
|
|
||||||
|--------|----------------|
|
|
||||||
| Transport | HTTPS required for all Jellyfin API calls |
|
|
||||||
| Certificate Validation | System CA store (configurable for self-signed) |
|
|
||||||
| Token Transmission | Bearer token in `Authorization` header only |
|
|
||||||
| Token Refresh | Handled by Jellyfin server (long-lived tokens) |
|
|
||||||
| Android cleartext | `res/xml/network_security_config.xml` blocks cleartext everywhere except `127.0.0.1` (the loopback media server, DR-137/DR-138). The manifest's `usesCleartextTraffic` is ignored once the config is present, so the config is the single authority |
|
|
||||||
| Android WebView | `mixedContentMode = COMPATIBILITY` with `allowFileAccess`/`allowContentAccess` both `false` (DR-199). These are the second half of the cleartext policy: `ALWAYS_ALLOW` re-opened by hand what the network security config closes. Change the two together |
|
|
||||||
|
|
||||||
## Webview Content Security Policy
|
|
||||||
|
|
||||||
`app.security.csp` in `tauri.conf.json` (TRACES: UR-012, UR-071 | DR-198). It was
|
|
||||||
`null` — CSP disabled — which meant any script that reached the web layer
|
|
||||||
inherited the full IPC surface. Tauri computes the header from this value when it
|
|
||||||
serves the embedded HTML, injecting a nonce for SvelteKit's inline bootstrap
|
|
||||||
script, so `script-src` needs no `'unsafe-inline'`.
|
|
||||||
|
|
||||||
```
|
|
||||||
default-src 'self';
|
|
||||||
script-src 'self';
|
|
||||||
style-src 'self' 'unsafe-inline';
|
|
||||||
font-src 'self' data:;
|
|
||||||
img-src 'self' data: blob: asset: http://asset.localhost http: https:;
|
|
||||||
media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:;
|
|
||||||
connect-src 'self' ipc: http://ipc.localhost http: https:;
|
|
||||||
worker-src 'self' blob:;
|
|
||||||
object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
|
|
||||||
```
|
|
||||||
|
|
||||||
| Directive | Why |
|
|
||||||
|-----------|-----|
|
|
||||||
| `default-src 'self'` | Everything not named below is same-origin only. |
|
|
||||||
| `script-src 'self'` | The genuinely restrictive half. Bundled JS only; Tauri's build-time nonce covers the one inline `<script>` in `index.html`. Adding `'unsafe-inline'` here would silently do nothing anyway — a nonce in a directive voids it. |
|
|
||||||
| `style-src 'self' 'unsafe-inline'` | Svelte compiles `style="…"` attributes into markup, including `app.html`'s `display: contents` wrapper, and CSP treats a style *attribute* as inline. Safe only while no `<style>` **element** survives into `index.html`: Tauri would nonce it, and the nonce would then void `'unsafe-inline'`. The production build extracts all CSS to files, so it currently has none. |
|
|
||||||
| `img-src` | Thumbnails come from two places: the asset protocol (`asset://localhost/…` on Linux/macOS, `http://asset.localhost/…` on Windows/Android — the same protocol, named differently by `convertFileSrc`) and, on a cache miss, straight from the Jellyfin server. `data:`/`blob:` cover inline and generated images. |
|
|
||||||
| `media-src` | `<video>`/`<audio>` sources: HLS transcodes and progressive streams from the server, the token-guarded loopback media server on `http://127.0.0.1:<random port>` (DR-137), and `blob:` for the MSE object URL hls.js attaches. |
|
|
||||||
| `connect-src` | `ipc:` / `http://ipc.localhost` is Tauri's `invoke` transport (custom scheme on Linux/macOS, `http` host on Windows/Android) — without it every command is blocked. `http:`/`https:` is hls.js fetching manifests and segments; ordinary API traffic goes through Rust and is not subject to CSP. |
|
|
||||||
| `worker-src 'self' blob:` | hls.js runs its demuxer in a worker built from a blob (`enableWorker: true`). Without `blob:` it falls back to main-thread demuxing — playback survives but costs more CPU. |
|
|
||||||
| `object-src`, `frame-src` = `'none'` | No plugins, no iframes; both are classic injection sinks. |
|
|
||||||
| `base-uri 'self'`, `form-action 'self'`, `frame-ancestors 'none'` | Block `<base>` hijacking, form exfiltration and framing. `frame-ancestors` is only honoured when the policy is delivered as a header, which is platform-dependent; it is harmless where it is not. |
|
|
||||||
|
|
||||||
**`img-src`/`media-src`/`connect-src` are deliberately permissive.** The Jellyfin
|
|
||||||
origin is typed in by the user at run time and is routinely plain `http` on a
|
|
||||||
LAN, so it cannot be enumerated at build time. `http: https:` is a wide grant for
|
|
||||||
*data* — but it still bars `file:`, `filesystem:` and scripting schemes, and it
|
|
||||||
does not touch `script-src`, which is where an injected origin would actually
|
|
||||||
hurt. A run-time policy naming the server exactly was considered and rejected:
|
|
||||||
Tauri derives the header from immutable config at the moment it serves the HTML,
|
|
||||||
so it would mean rebuilding the config and reloading the webview whenever the
|
|
||||||
user adds or switches a server, to constrain a destination the user chooses
|
|
||||||
anyway.
|
|
||||||
|
|
||||||
`devCsp` mirrors the policy with `'unsafe-inline' 'unsafe-eval'` on `script-src`
|
|
||||||
and `ws:`/`wss:` on `connect-src`, because the Vite dev server injects styles and
|
|
||||||
code and drives HMR over a websocket. It applies only to `tauri dev`.
|
|
||||||
|
|
||||||
### Asset protocol scope
|
|
||||||
|
|
||||||
`app.security.assetProtocol.scope` is `$APPDATA/thumbnails/**` — not the storage
|
|
||||||
root. `imageCache.ts` is the only `convertFileSrc` caller left in the frontend:
|
|
||||||
downloaded media moved to the loopback media server in DR-137, and downloaded
|
|
||||||
audio is opened by MPV/ExoPlayer directly from its path. The old `$APPDATA/**`
|
|
||||||
grant let the webview read the SQLite database and the encrypted-token fallback
|
|
||||||
file alongside the thumbnails it actually needs.
|
|
||||||
|
|
||||||
If a new feature hands the webview a local file, widen this scope to that
|
|
||||||
subdirectory specifically; a path outside it resolves to nothing and the webview
|
|
||||||
reports `NETWORK_NO_SOURCE` (which is exactly how DR-134's failure presented).
|
|
||||||
|
|
||||||
## Local Data Protection
|
|
||||||
|
|
||||||
| Data Type | Protection |
|
|
||||||
|-----------|------------|
|
|
||||||
| Access Tokens | System keyring or encrypted file |
|
|
||||||
| Database (SQLite) | Plaintext (metadata only, no secrets) |
|
|
||||||
| Downloaded Media | Filesystem permissions only |
|
|
||||||
| Cached Thumbnails | Filesystem permissions only |
|
|
||||||
|
|
||||||
## Path Confinement and Input Binding
|
|
||||||
|
|
||||||
Two classes of defect, both of the same *shape*: a value that arrived from
|
|
||||||
outside decided something it should not, at a site whose neighbours a few lines
|
|
||||||
away already did it correctly.
|
|
||||||
|
|
||||||
### Filesystem path confinement
|
|
||||||
|
|
||||||
| Surface | Rule | TRACES |
|
|
||||||
|---------|------|--------|
|
|
||||||
| Thumbnail cache | The filename is built from `item_id`, `image_type` and `tag`; all three are sanitised (non-alphanumerics → `_`), and the resolved path is checked with `starts_with(cache_dir)` **at the point of use** | DR-210 |
|
|
||||||
| Downloads | `file_path` and `target_dir` are sanitised inside `download_item` itself, not only in `download_item_and_start` — the latter is what made the existing guard bypassable rather than absent | DR-211 |
|
|
||||||
|
|
||||||
Two mechanics worth remembering, because both are easy to get subtly wrong:
|
|
||||||
|
|
||||||
- `Path::join` **neither folds `..` nor keeps the base when handed an absolute
|
|
||||||
path**. Confinement therefore has to be checked *after* the join, not before.
|
|
||||||
- Sanitising is **per path component**. Whole-string sanitising would rewrite
|
|
||||||
`downloads/x.mp3` to `downloads_x.mp3` and relocate every existing download.
|
|
||||||
|
|
||||||
The database keeps both the raw key and the resolved path, so lookups still match
|
|
||||||
and pre-existing rows still resolve.
|
|
||||||
|
|
||||||
### Query and URL construction
|
|
||||||
|
|
||||||
Caller-supplied values are **bound or encoded**, never interpolated (DR-212):
|
|
||||||
|
|
||||||
- The offline `get_items` item-type filter uses parameter placeholders rather
|
|
||||||
than formatting `IN ('a','b')`.
|
|
||||||
- `build_get_items_endpoint` encodes `ParentId` / `IncludeItemTypes` / `SortBy` /
|
|
||||||
`SortOrder`. Encoding is **per element** and list separators stay unencoded,
|
|
||||||
because Jellyfin splits these parameters on the comma.
|
|
||||||
- `player_set_volume` clamps at the command boundary — it previously accepted
|
|
||||||
NaN and out-of-range floats even though every backend clamps internally.
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
1. **No Secrets in SQLite**: The database contains only non-sensitive metadata
|
|
||||||
2. **Token Isolation**: Each user/server combination has a separate token entry
|
|
||||||
3. **Logout Cleanup**: Token deletion from secure storage on logout
|
|
||||||
4. **No Token Logging**: Tokens are never written to logs or debug output
|
|
||||||
5. **IPC Security**: Tauri's IPC uses structured commands, not arbitrary code execution
|
|
||||||
6. **Webview Containment**: A restrictive `script-src` keeps injected script off the IPC surface; the asset protocol is scoped to the thumbnail cache only (see above)
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
# JellyTau Software Architecture
|
|
||||||
|
|
||||||
This document describes the current architecture of JellyTau, a cross-platform Jellyfin client built with Tauri, SvelteKit, and Rust.
|
|
||||||
|
|
||||||
**Last Updated:** 2026-06-20
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
JellyTau uses a client-server architecture: business logic lives in a comprehensive Rust backend, while a UI-rich Svelte frontend handles presentation and interaction.
|
|
||||||
|
|
||||||
### Architecture Principles
|
|
||||||
|
|
||||||
- **Business Logic in Rust**: Core logic — playback, repository, sync, downloads, connectivity — lives in Rust for performance, reliability, and type safety.
|
|
||||||
- **Presentation in Svelte**: The frontend (~20.5k non-test lines) owns UI, layout, navigation, and interaction state and invokes Rust commands. It is intentionally UI-heavy, **not** a thin wrapper. Largest pieces: components + routes (~14.6k lines), stores (~3.4k), api/services/utils (~2.4k); `VideoPlayer.svelte` alone is ~1.6k lines.
|
|
||||||
- **Events + Polling hybrid**: Rust emits events the frontend listens to, and the UI also polls status on short intervals in a few hot spots (e.g. queue status in `library/+layout.svelte`, playback progress in `VideoPlayer.svelte`).
|
|
||||||
- **Unified player boundary**: UI components control playback only through the frontend facade `src/lib/player/index.ts` (`playerController`), never by calling `commands.player*` directly. Webview-rendered HTML5 video reports its state back into Rust via `src/lib/player/html5Adapter.ts` and the `player_report_*` commands, so the `PlayerController` stays the single source of truth in both native (MPV/ExoPlayer) and HTML5 modes (see [05-platform-backends.md](05-platform-backends.md)).
|
|
||||||
- **Handle-Based Resources**: UUID handles for stateful Rust objects.
|
|
||||||
- **Cache-First**: Parallel queries with intelligent fallback.
|
|
||||||
- **Single source of truth for reachability**: Server reachability is derived from the outcome of *real repository traffic*, not a side-channel poller. The `OnlineRepository` reports each server result to the `ConnectivityMonitor` (classified via `RepoError`), which applies a time-window debounce before declaring the server offline and recovers instantly on the first success. The standalone `/System/Info/Public` probe runs *only while offline*, as a recovery detector for idle sessions.
|
|
||||||
- **Poison-tolerant locking**: Shared `std::sync` state is accessed via the `MutexSafe`/`RwLockSafe` helpers in `utils/lock.rs`, which recover a poisoned lock instead of cascading a panic across the player.
|
|
||||||
- **Graceful backend init**: If a native player backend (MPV/ExoPlayer) fails to initialize, the app falls back to a no-op backend and emits a `backend-init-failed` event rather than crashing.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
|
||||||
subgraph Frontend["Svelte Frontend"]
|
|
||||||
subgraph Stores["Stores (Thin Wrappers)"]
|
|
||||||
auth["auth"]
|
|
||||||
player["player"]
|
|
||||||
queue["queue"]
|
|
||||||
library["library"]
|
|
||||||
connectivity["connectivity"]
|
|
||||||
playbackMode["playbackMode"]
|
|
||||||
end
|
|
||||||
subgraph Components
|
|
||||||
playerComp["player/"]
|
|
||||||
libraryComp["library/"]
|
|
||||||
Search["Search"]
|
|
||||||
end
|
|
||||||
subgraph Routes
|
|
||||||
routeLibrary["/library"]
|
|
||||||
routePlayer["/player"]
|
|
||||||
routeRoot["/"]
|
|
||||||
end
|
|
||||||
subgraph API["API Layer (Thin Client)"]
|
|
||||||
RepositoryClient["RepositoryClient<br/>(Handle-based)"]
|
|
||||||
JellyfinClient["JellyfinClient<br/>(Helper)"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
Frontend -->|"Tauri IPC (invoke)"| Backend
|
|
||||||
|
|
||||||
subgraph Backend["Rust Backend (Business Logic)"]
|
|
||||||
subgraph Commands["Tauri Commands (90+)"]
|
|
||||||
PlayerCmds["player.rs"]
|
|
||||||
RepoCmds["repository.rs (27)"]
|
|
||||||
PlaybackModeCmds["playback_mode.rs (5)"]
|
|
||||||
StorageCmds["storage.rs"]
|
|
||||||
ConnectivityCmds["connectivity.rs (7)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Core["Core Modules"]
|
|
||||||
MediaSessionManager["MediaSessionManager<br/>(Audio/Movie/TvShow/Idle)"]
|
|
||||||
|
|
||||||
PlayerController["PlayerController<br/>+ PlayerBackend<br/>+ QueueManager"]
|
|
||||||
|
|
||||||
Repository["Repository Layer<br/>HybridRepository (cache-first)<br/>OnlineRepository (HTTP)<br/>OfflineRepository (SQLite)"]
|
|
||||||
|
|
||||||
PlaybackModeManager["PlaybackModeManager<br/>(Local/Remote/Idle)"]
|
|
||||||
|
|
||||||
ConnectivityMonitor["ConnectivityMonitor<br/>(Adaptive polling)"]
|
|
||||||
|
|
||||||
HttpClient["HttpClient<br/>(Exponential backoff retry)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Storage["Storage Layer"]
|
|
||||||
DatabaseService["DatabaseService<br/>(Async trait)"]
|
|
||||||
SQLite["SQLite Database<br/>(13 tables)"]
|
|
||||||
end
|
|
||||||
|
|
||||||
Commands --> Core
|
|
||||||
Core --> Storage
|
|
||||||
Repository --> HttpClient
|
|
||||||
Repository --> DatabaseService
|
|
||||||
Repository -->|"reports server outcome<br/>(success / RepoError)"| ConnectivityMonitor
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
> The `Repository --> ConnectivityMonitor` edge is the source of truth for the offline/online banner: every server request the user actually makes updates reachability. The monitor's own polling is now an offline-only recovery probe (see [07-connectivity.md](07-connectivity.md)).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Detailed Documentation
|
|
||||||
|
|
||||||
Each major subsystem is documented in its own file in this directory:
|
|
||||||
|
|
||||||
| Document | Contents |
|
|
||||||
|----------|----------|
|
|
||||||
| [01 - Rust Backend](01-rust-backend.md) | Media session state machine, player state machine, playback mode, media items, queue manager, favorites (marking + browsing), player backend trait, player controller, playlist system, **domain vocabulary owned by Rust** (search scope, library exclusions, streaming quality ladder), **background workers** (catalog indexer, drains), Tauri commands |
|
|
||||||
| [02 - Svelte Frontend](02-svelte-frontend.md) | Store structure, music library navigation, playback reporting, repository architecture, playback mode system, database service abstraction, component hierarchy, MiniPlayer, sleep timer, auto-play, navigation guard, playlist management UI, library mosaic, series/episode navigation, downloaded browse, safe-area insets, native-video store, logging |
|
|
||||||
| [03 - Data Flow](03-data-flow.md) | Repository query flow (cache-first), locally-indexed search, playback initiation, playback mode transfer, queue navigation, volume control |
|
|
||||||
| [04 - Type Sync & Threading](04-type-sync-and-threading.md) | Rust/TypeScript type synchronization, Tauri v2 IPC parameter naming convention, thread safety patterns |
|
|
||||||
| [05 - Platform Backends](05-platform-backends.md) | Player events system, HTML5 video adapter, MpvBackend (Linux), ExoPlayerBackend (Android) incl. audio settings parity, **native video compositing**, MediaSession & remote volume, album art caching, backend initialization |
|
|
||||||
| [06 - Downloads & Offline](06-downloads-and-offline.md) | Download manager, download worker, smart caching engine, **one storage model (cache entries are downloads)**, offline catalog visibility, download/offline commands, player integration, frontend store, UI components |
|
|
||||||
| [07 - Connectivity](07-connectivity.md) | HTTP client with retry logic, connectivity monitor, network resilience architecture |
|
|
||||||
| [08 - Database Design](08-database-design.md) | Entity relationships, all table definitions (servers, users, libraries, items, user_data, downloads, media_streams, sync_queue, thumbnails, playlists), key queries, data flow diagrams, storage estimates |
|
|
||||||
| [09 - Security](09-security.md) | Authentication token storage, secure storage module, network security, webview CSP + asset-protocol scope, **path confinement and input binding**, local data protection |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure Summary
|
|
||||||
|
|
||||||
```
|
|
||||||
src-tauri/src/
|
|
||||||
├── lib.rs # Tauri app setup, state initialization
|
|
||||||
├── commands/ # Tauri command handlers (~245 #[tauri::command] fns)
|
|
||||||
│ ├── mod.rs # Command exports
|
|
||||||
│ ├── player/ # Player commands: queue, remote, session, settings, timers
|
|
||||||
│ ├── repository.rs # Repository commands (items, search, favourites, disk usage)
|
|
||||||
│ ├── catalog.rs # Catalog sync + the background index pass
|
|
||||||
│ ├── favorites.rs # Offline favourite drain
|
|
||||||
│ ├── library.rs # Library listing + folder exclusions
|
|
||||||
│ ├── playlist.rs # Playlist commands
|
|
||||||
│ ├── playback_mode.rs # Local/remote transfer
|
|
||||||
│ ├── playback_reporting.rs
|
|
||||||
│ ├── connectivity.rs # Connectivity commands
|
|
||||||
│ ├── storage/ # Storage & database commands: people, series_prefs, thumbnails
|
|
||||||
│ ├── download/ # Download commands: mod, pinning, smart_cache
|
|
||||||
│ ├── offline.rs # Offline commands
|
|
||||||
│ ├── device.rs # Device id / capabilities
|
|
||||||
│ ├── sessions.rs # Remote sessions
|
|
||||||
│ ├── sync.rs # Sync queue commands
|
|
||||||
│ └── sync_drain.rs # Background sync-queue drain
|
|
||||||
├── repository/ # Repository pattern implementation
|
|
||||||
│ ├── mod.rs # MediaRepository trait, handle management
|
|
||||||
│ ├── types.rs # RepoError, Library, MediaItem, etc.
|
|
||||||
│ ├── hybrid.rs # HybridRepository with cache-first racing
|
|
||||||
│ ├── online.rs # OnlineRepository (HTTP API)
|
|
||||||
│ └── offline.rs # OfflineRepository (SQLite queries)
|
|
||||||
├── playback_mode/ # Playback mode manager
|
|
||||||
│ └── mod.rs # PlaybackMode enum, transfer logic
|
|
||||||
├── connectivity/ # Connectivity monitoring
|
|
||||||
│ └── mod.rs # ConnectivityMonitor, adaptive polling
|
|
||||||
├── jellyfin/ # Jellyfin API client
|
|
||||||
│ ├── mod.rs # Module exports
|
|
||||||
│ ├── http_client.rs # HTTP client with retry logic
|
|
||||||
│ └── client.rs # JellyfinClient for API calls
|
|
||||||
├── storage/ # Database layer
|
|
||||||
│ ├── mod.rs # Database struct, migrations
|
|
||||||
│ ├── db_service.rs # DatabaseService trait (async wrapper)
|
|
||||||
│ ├── schema.rs # Table definitions
|
|
||||||
│ └── queries/ # Query modules
|
|
||||||
├── download/ # Download manager module
|
|
||||||
│ ├── mod.rs # DownloadManager, DownloadInfo, DownloadTask
|
|
||||||
│ ├── worker.rs # DownloadWorker, HTTP streaming, retry logic
|
|
||||||
│ ├── events.rs # DownloadEvent enum
|
|
||||||
│ └── cache.rs # SmartCache, CacheConfig, LRU eviction
|
|
||||||
└── player/ # Player subsystem
|
|
||||||
├── mod.rs # PlayerController
|
|
||||||
├── session.rs # MediaSessionManager, MediaSessionType
|
|
||||||
├── state.rs # PlayerState, PlayerEvent
|
|
||||||
├── media.rs # MediaItem, MediaSource, MediaType
|
|
||||||
├── queue.rs # QueueManager, RepeatMode
|
|
||||||
├── backend.rs # PlayerBackend trait, NullBackend
|
|
||||||
├── events.rs # PlayerStatusEvent, TauriEventEmitter
|
|
||||||
├── mpv/ # Linux MPV backend
|
|
||||||
│ ├── mod.rs # MpvBackend implementation
|
|
||||||
│ └── event_loop.rs # Dedicated thread for MPV operations
|
|
||||||
└── android/ # Android ExoPlayer backend
|
|
||||||
└── mod.rs # ExoPlayerBackend + JNI bindings
|
|
||||||
|
|
||||||
src/lib/
|
|
||||||
├── api/ # Thin API layer (~200 lines total)
|
|
||||||
│ ├── types.ts # TypeScript type definitions
|
|
||||||
│ ├── repository-client.ts # RepositoryClient wrapper (~100 lines)
|
|
||||||
│ ├── client.ts # JellyfinClient (helper for streaming)
|
|
||||||
│ └── sessions.ts # SessionsApi (remote session control)
|
|
||||||
├── player/ # Unified player boundary (frontend)
|
|
||||||
│ ├── index.ts # playerController facade — the only write-side entry point for playback
|
|
||||||
│ └── html5Adapter.ts # Reports webview <video> DOM events back into Rust (player_report_*)
|
|
||||||
├── services/
|
|
||||||
│ ├── playerEvents.ts # Tauri event listener for player events
|
|
||||||
│ └── playbackReporting.ts # Thin wrapper (~50 lines)
|
|
||||||
├── stores/ # Thin reactive wrappers over Rust commands
|
|
||||||
│ ├── index.ts # Re-exports
|
|
||||||
│ ├── auth.ts # Auth store (calls Rust commands)
|
|
||||||
│ ├── player.ts # Player store
|
|
||||||
│ ├── queue.ts # Queue store
|
|
||||||
│ ├── library.ts # Library store
|
|
||||||
│ ├── playbackMode.ts # Playback mode store (~150 lines)
|
|
||||||
│ ├── connectivity.ts # Connectivity store (~250 lines)
|
|
||||||
│ └── downloads.ts # Downloads store with event listeners
|
|
||||||
└── components/
|
|
||||||
├── Search.svelte
|
|
||||||
├── player/ # Player UI components
|
|
||||||
├── playlist/ # Playlist modals (Create, AddTo)
|
|
||||||
├── sessions/ # Remote session control UI
|
|
||||||
├── downloads/ # Download UI components
|
|
||||||
└── library/ # Library UI components + PlaylistDetailView
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Architecture Changes
|
|
||||||
|
|
||||||
**What moved to Rust (~3,500 lines of business logic):**
|
|
||||||
1. **HTTP Client** (338 lines) - Retry logic with exponential backoff
|
|
||||||
2. **Connectivity Monitor** (301 lines) - Reachability derived from real repository traffic, time-window debounce, offline-only recovery probe, event emission
|
|
||||||
3. **Repository Pattern** (1061 lines) - Cache-first hybrid with parallel racing
|
|
||||||
4. **Database Service** - Async wrapper preventing UI freezing
|
|
||||||
5. **Playback Mode** (303 lines) - Local/remote transfer coordination
|
|
||||||
|
|
||||||
**Svelte/TypeScript frontend (~20.5k non-test lines, plus ~9.6k test lines):**
|
|
||||||
- Components + routes (~14.6k lines) — UI and presentation
|
|
||||||
- Stores (~3.4k lines) — reactive state that invokes Rust commands and listens for events
|
|
||||||
- api / services / utils (~2.4k lines) — typed clients, event listeners, conversion helpers
|
|
||||||
|
|
||||||
The frontend is genuinely UI-heavy; business decisions live in Rust, but the UI owns layout, navigation, and interaction state.
|
|
||||||
|
|
||||||
**Total Commands:** ~245 `#[tauri::command]` functions across 17 command modules
|
|
||||||
(~58k lines of Rust, ~37k non-test lines of TypeScript/Svelte).
|
|
||||||
|
|
||||||
> Counts and line totals in this file are periodic snapshots, not gates — the
|
|
||||||
> authority is the tree. Regenerate with
|
|
||||||
> `grep -rc '#\[tauri::command\]' src-tauri/src` and `wc -l`.
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 142 KiB |
Vendored
-156
@@ -1,156 +0,0 @@
|
|||||||
# Building and Pushing the JellyTau Builder Image
|
|
||||||
|
|
||||||
This document explains how to create and push the pre-built builder Docker image to your registry for use in Gitea Act CI/CD.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Docker installed and running
|
|
||||||
- Access to your Docker registry (e.g., `gitea.tourolle.paris`)
|
|
||||||
- Docker registry credentials configured (`docker login`)
|
|
||||||
|
|
||||||
## Building the Builder Image
|
|
||||||
|
|
||||||
### Step 1: Build the Image Locally
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From the project root
|
|
||||||
docker build -f Dockerfile.builder -t jellytau-builder:latest .
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates a local image with:
|
|
||||||
- All system dependencies
|
|
||||||
- Rust with Android targets
|
|
||||||
- Android SDK and NDK
|
|
||||||
- Node.js and Bun
|
|
||||||
- All build tools pre-installed
|
|
||||||
|
|
||||||
### Step 2: Tag for Your Registry
|
|
||||||
|
|
||||||
Replace `gitea.tourolle.paris/dtourolle` with your actual registry path:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Login to Your Registry
|
|
||||||
|
|
||||||
If not already logged in:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker login gitea.tourolle.paris
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Push to Registry
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## Complete One-Liner
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -f Dockerfile.builder -t jellytau-builder:latest . && \
|
|
||||||
docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-builder:latest && \
|
|
||||||
docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verifying the Build
|
|
||||||
|
|
||||||
Check that the image was pushed successfully:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List images in your registry (depends on registry API support)
|
|
||||||
docker search gitea.tourolle.paris/dtourolle/jellytau-builder
|
|
||||||
|
|
||||||
# Or pull and test locally
|
|
||||||
docker pull gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
docker run -it gitea.tourolle.paris/dtourolle/jellytau-builder:latest bun --version
|
|
||||||
```
|
|
||||||
|
|
||||||
## Using in CI/CD
|
|
||||||
|
|
||||||
The workflow at `.gitea/workflows/build-and-test.yml` automatically uses:
|
|
||||||
```yaml
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
Once pushed, your CI/CD pipeline will use this pre-built image instead of installing everything during the build, saving significant time.
|
|
||||||
|
|
||||||
## Updating the Builder Image
|
|
||||||
|
|
||||||
When dependencies change (new Rust version, Android SDK update, etc.):
|
|
||||||
|
|
||||||
1. Update `Dockerfile.builder` with the new configuration
|
|
||||||
2. Rebuild and push with a new tag:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -f Dockerfile.builder -t jellytau-builder:v1.2.0 .
|
|
||||||
docker tag jellytau-builder:v1.2.0 gitea.tourolle.paris/dtourolle/jellytau-builder:v1.2.0
|
|
||||||
docker push gitea.tourolle.paris/dtourolle/jellytau-builder:v1.2.0
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Update the workflow to use the new tag:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:v1.2.0
|
|
||||||
```
|
|
||||||
|
|
||||||
## Image Contents
|
|
||||||
|
|
||||||
The builder image includes:
|
|
||||||
|
|
||||||
- **Base OS**: Ubuntu 24.04
|
|
||||||
- **Languages**:
|
|
||||||
- Rust (stable) with targets: aarch64-linux-android, armv7-linux-androideabi, x86_64-linux-android
|
|
||||||
- Node.js 20.x
|
|
||||||
- OpenJDK 17 (for Android)
|
|
||||||
- **Tools**:
|
|
||||||
- Bun package manager
|
|
||||||
- Android SDK 34
|
|
||||||
- Android NDK 27.0.11902837
|
|
||||||
- Build essentials (gcc, make, etc.)
|
|
||||||
- Git, curl, wget
|
|
||||||
- libssl, libclang development libraries
|
|
||||||
- **Pre-configured**:
|
|
||||||
- Rust toolchain components (rustfmt, clippy)
|
|
||||||
- Android SDK/NDK environment variables
|
|
||||||
- All paths optimized for building
|
|
||||||
|
|
||||||
## Build Time
|
|
||||||
|
|
||||||
First build takes ~15-20 minutes depending on internet speed (downloads Android SDK/NDK).
|
|
||||||
Subsequent builds are cached and take seconds.
|
|
||||||
|
|
||||||
## Storage
|
|
||||||
|
|
||||||
The built image is approximately **4-5 GB**. Ensure your registry has sufficient storage.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### "Image not found" in CI
|
|
||||||
- Verify the image name matches exactly in the workflow
|
|
||||||
- Check that the image was successfully pushed: `docker push` output should show successful layers
|
|
||||||
- Ensure Gitea has access to your registry (check network/firewall)
|
|
||||||
|
|
||||||
### Build fails with "command not found"
|
|
||||||
- The image may not have finished pushing. Wait a few moments and retry the CI job.
|
|
||||||
- Check that all layers were pushed successfully in the push output.
|
|
||||||
|
|
||||||
### Registry authentication in CI
|
|
||||||
If your registry requires credentials in CI:
|
|
||||||
1. Create a deploy token in your registry
|
|
||||||
2. Add to Gitea secrets as `REGISTRY_USERNAME` and `REGISTRY_TOKEN`
|
|
||||||
3. Use in workflow:
|
|
||||||
```yaml
|
|
||||||
- name: Login to Registry
|
|
||||||
run: |
|
|
||||||
docker login gitea.tourolle.paris -u ${{ secrets.REGISTRY_USERNAME }} -p ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
```
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [Docker Build Documentation](https://docs.docker.com/build/)
|
|
||||||
- [Docker Push Documentation](https://docs.docker.com/engine/reference/commandline/push/)
|
|
||||||
- [Dockerfile Reference](https://docs.docker.com/engine/reference/builder/)
|
|
||||||
Vendored
-91
@@ -1,91 +0,0 @@
|
|||||||
# 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.
|
|
||||||
Vendored
-360
@@ -1,360 +0,0 @@
|
|||||||
# Build & Release Workflow
|
|
||||||
|
|
||||||
This document explains the automated build and release process for JellyTau.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The CI/CD pipeline automatically:
|
|
||||||
1. ✅ Runs all tests (frontend + Rust)
|
|
||||||
2. ✅ Builds Linux binaries (AppImage + DEB)
|
|
||||||
3. ✅ Builds Android APK and AAB
|
|
||||||
4. ✅ Creates releases with artifacts
|
|
||||||
5. ✅ Tags releases with version numbers
|
|
||||||
|
|
||||||
## Workflow Triggers
|
|
||||||
|
|
||||||
### Automatic Trigger
|
|
||||||
When you push a version tag:
|
|
||||||
```bash
|
|
||||||
git tag v1.0.0
|
|
||||||
git push origin v1.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
The workflow automatically:
|
|
||||||
1. Runs tests
|
|
||||||
2. Builds both platforms
|
|
||||||
3. Creates a GitHub release with artifacts
|
|
||||||
4. Tags it as release/prerelease based on version
|
|
||||||
|
|
||||||
### Manual Trigger
|
|
||||||
In Gitea Actions UI:
|
|
||||||
1. Go to **Actions** tab
|
|
||||||
2. Click **Build & Release** workflow
|
|
||||||
3. Click **Run workflow**
|
|
||||||
4. Optionally specify a version
|
|
||||||
5. Workflow runs without creating a release
|
|
||||||
|
|
||||||
## Version Tagging
|
|
||||||
|
|
||||||
### Format
|
|
||||||
Version tags follow semantic versioning: `v{MAJOR}.{MINOR}.{PATCH}`
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- `v1.0.0` - Release version
|
|
||||||
- `v1.0.0-rc1` - Release candidate (marked as prerelease)
|
|
||||||
- `v1.0.0-beta` - Beta version (marked as prerelease)
|
|
||||||
- `v0.1.0-alpha` - Alpha version (marked as prerelease)
|
|
||||||
|
|
||||||
### Creating a Release
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create and push a version tag
|
|
||||||
git tag v1.0.0 -m "Release version 1.0.0"
|
|
||||||
git push origin v1.0.0
|
|
||||||
|
|
||||||
# Or create from main branch
|
|
||||||
git tag -a v1.0.0 -m "Release version 1.0.0" main
|
|
||||||
git push origin v1.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
### Release Status
|
|
||||||
|
|
||||||
Versions containing `rc`, `beta`, or `alpha` are marked as **prerelease**:
|
|
||||||
```bash
|
|
||||||
git tag v1.0.0-rc1 # ⚠️ Prerelease
|
|
||||||
git tag v1.0.0-beta # ⚠️ Prerelease
|
|
||||||
git tag v1.0.0-alpha # ⚠️ Prerelease
|
|
||||||
git tag v1.0.0 # ✅ Full release
|
|
||||||
```
|
|
||||||
|
|
||||||
## Workflow Steps
|
|
||||||
|
|
||||||
### 1. Test Phase
|
|
||||||
Runs on all tags and manual triggers:
|
|
||||||
- Frontend tests (`vitest`)
|
|
||||||
- Rust tests (`cargo test`)
|
|
||||||
- TypeScript type checking
|
|
||||||
|
|
||||||
**Failure:** Stops workflow, no build/release
|
|
||||||
|
|
||||||
### 2. Build Linux Phase
|
|
||||||
Runs after tests pass:
|
|
||||||
- Installs system dependencies
|
|
||||||
- Builds with Tauri
|
|
||||||
- Generates:
|
|
||||||
- **AppImage** - Universal Linux binary
|
|
||||||
- **DEB** - Debian/Ubuntu package
|
|
||||||
|
|
||||||
**Output:** `artifacts/linux/`
|
|
||||||
|
|
||||||
### 3. Build Android Phase
|
|
||||||
Runs in parallel with Linux build:
|
|
||||||
- Installs Android SDK/NDK
|
|
||||||
- Configures Rust for Android targets
|
|
||||||
- Builds with Tauri
|
|
||||||
- Generates:
|
|
||||||
- **APK** - Android app package (installable)
|
|
||||||
- **AAB** - Android App Bundle (for Play Store)
|
|
||||||
|
|
||||||
**Output:** `artifacts/android/`
|
|
||||||
|
|
||||||
### 4. Create Release Phase
|
|
||||||
Runs after both builds succeed (only on version tags):
|
|
||||||
- Prepares release notes
|
|
||||||
- Downloads build artifacts
|
|
||||||
- Creates GitHub/Gitea release
|
|
||||||
- Uploads all artifacts
|
|
||||||
- Tags as prerelease if applicable
|
|
||||||
|
|
||||||
## Artifacts
|
|
||||||
|
|
||||||
### Linux Artifacts
|
|
||||||
|
|
||||||
#### AppImage
|
|
||||||
- **File:** `jellytau_*.AppImage`
|
|
||||||
- **Size:** ~100-150 MB
|
|
||||||
- **Use:** Run directly on any Linux distro
|
|
||||||
- **Installation:**
|
|
||||||
```bash
|
|
||||||
chmod +x JellyTau_*.AppImage
|
|
||||||
./JellyTau_*.AppImage
|
|
||||||
```
|
|
||||||
|
|
||||||
#### DEB Package
|
|
||||||
- **File:** `JellyTau_*.deb`
|
|
||||||
- **Size:** ~80-120 MB
|
|
||||||
- **Use:** Install on Debian/Ubuntu/similar
|
|
||||||
- **Installation:**
|
|
||||||
```bash
|
|
||||||
sudo dpkg -i JellyTau_*.deb
|
|
||||||
jellytau
|
|
||||||
```
|
|
||||||
- **Note:** the Debian package is named `jelly-tau` (Tauri kebab-cases
|
|
||||||
`productName`), while the command stays `jellytau`. The package declares
|
|
||||||
`Replaces`/`Conflicts`/`Provides: jellytau`, so upgrading from a release built
|
|
||||||
before the rename replaces it rather than installing a second copy.
|
|
||||||
|
|
||||||
#### RPM Package
|
|
||||||
- **File:** `JellyTau-*.rpm`
|
|
||||||
- **Use:** Install on Fedora/openSUSE/similar
|
|
||||||
- **Installation:**
|
|
||||||
```bash
|
|
||||||
sudo rpm -i JellyTau-*.rpm
|
|
||||||
jellytau
|
|
||||||
```
|
|
||||||
|
|
||||||
### Android Artifacts
|
|
||||||
|
|
||||||
#### APK
|
|
||||||
- **File:** `jellytau-release.apk`
|
|
||||||
- **Size:** ~60-100 MB
|
|
||||||
- **Use:** Direct installation on Android devices
|
|
||||||
- **Installation:**
|
|
||||||
```bash
|
|
||||||
adb install jellytau-release.apk
|
|
||||||
# Or sideload via file manager
|
|
||||||
```
|
|
||||||
|
|
||||||
#### AAB (Android App Bundle)
|
|
||||||
- **File:** `jellytau-release.aab`
|
|
||||||
- **Size:** ~50-90 MB
|
|
||||||
- **Use:** Upload to Google Play Console
|
|
||||||
- **Note:** Cannot be installed directly; for Play Store distribution
|
|
||||||
|
|
||||||
## Release Notes
|
|
||||||
|
|
||||||
Release notes are automatically generated with:
|
|
||||||
- Version number
|
|
||||||
- Download links
|
|
||||||
- Installation instructions
|
|
||||||
- System requirements
|
|
||||||
- Known issues link
|
|
||||||
- Changelog reference
|
|
||||||
|
|
||||||
## Build Matrix
|
|
||||||
|
|
||||||
| Platform | OS | Architecture | Format |
|
|
||||||
|----------|----|----|--------|
|
|
||||||
| **Linux** | Any | x86_64 | AppImage, DEB |
|
|
||||||
| **Android** | 8.0+ | arm64, armv7, x86_64 | APK, AAB |
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Build Fails During Test Phase
|
|
||||||
1. Check test output in Gitea Actions
|
|
||||||
2. Run tests locally: `bun run test` and `bun run test:rust`
|
|
||||||
3. Fix failing tests
|
|
||||||
4. Create new tag with fixed code
|
|
||||||
|
|
||||||
### Linux Build Fails
|
|
||||||
1. Check system dependencies installed
|
|
||||||
2. Verify Tauri configuration
|
|
||||||
3. Check cargo dependencies
|
|
||||||
4. Clear cache: Delete `.cargo` and `target/` directories
|
|
||||||
|
|
||||||
### Android Build Fails
|
|
||||||
1. Check Android SDK/NDK setup
|
|
||||||
2. Verify Java 17 is installed
|
|
||||||
3. Check Rust Android targets: `rustup target list`
|
|
||||||
4. Clear cache and rebuild
|
|
||||||
|
|
||||||
### Release Not Created
|
|
||||||
1. Tag must start with `v` (e.g., `v1.0.0`)
|
|
||||||
2. Tests must pass
|
|
||||||
3. Both builds must succeed
|
|
||||||
4. Check workflow logs for errors
|
|
||||||
|
|
||||||
## GitHub Release vs Gitea
|
|
||||||
|
|
||||||
The workflow uses GitHub Actions SDK but is designed for Gitea. For Gitea-native releases:
|
|
||||||
|
|
||||||
1. Workflow creates artifacts
|
|
||||||
2. Artifacts are available in Actions artifacts
|
|
||||||
3. Download and manually create Gitea release, or
|
|
||||||
4. Set up Gitea API integration to auto-publish
|
|
||||||
|
|
||||||
## Customization
|
|
||||||
|
|
||||||
### Change Release Notes Template
|
|
||||||
|
|
||||||
Edit `.gitea/workflows/build-release.yml`, section `Prepare release notes`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Prepare release notes
|
|
||||||
id: release_notes
|
|
||||||
run: |
|
|
||||||
# Add your custom release notes format here
|
|
||||||
echo "Custom notes" > release_notes.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### Add New Platforms
|
|
||||||
|
|
||||||
To add macOS or Windows builds:
|
|
||||||
|
|
||||||
1. Add new `build-{platform}` job
|
|
||||||
2. Set appropriate `runs-on` runner
|
|
||||||
3. Add platform-specific dependencies
|
|
||||||
4. Update artifact upload
|
|
||||||
5. Include in `needs: [build-linux, build-android, build-{platform}]`
|
|
||||||
|
|
||||||
### Change Build Targets
|
|
||||||
|
|
||||||
Modify Tauri configuration or add targets:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Build for Linux
|
|
||||||
run: |
|
|
||||||
# Add target specification
|
|
||||||
bun run tauri build -- --target x86_64-unknown-linux-gnu
|
|
||||||
```
|
|
||||||
|
|
||||||
## Monitoring
|
|
||||||
|
|
||||||
### Check Status
|
|
||||||
1. Go to **Actions** tab in Gitea
|
|
||||||
2. View **Build & Release** workflow runs
|
|
||||||
3. Click specific run to see logs
|
|
||||||
|
|
||||||
### Notifications
|
|
||||||
Set up notifications for:
|
|
||||||
- Build failures
|
|
||||||
- Release creation
|
|
||||||
- Tag pushes
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
### Build Times (Approximate)
|
|
||||||
- Test phase: 5-10 minutes
|
|
||||||
- Linux build: 10-15 minutes
|
|
||||||
- Android build: 15-20 minutes
|
|
||||||
- Total: 30-45 minutes
|
|
||||||
|
|
||||||
### Caching
|
|
||||||
Workflow caches:
|
|
||||||
- Rust dependencies (cargo)
|
|
||||||
- Bun node_modules
|
|
||||||
- Android SDK components
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
### Secrets
|
|
||||||
The workflow uses:
|
|
||||||
- `GITHUB_TOKEN` - Built-in, no setup needed
|
|
||||||
- No credentials needed for Gitea
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
To verify build integrity:
|
|
||||||
1. Download artifacts
|
|
||||||
2. Verify signatures (if implemented)
|
|
||||||
3. Check file hashes
|
|
||||||
4. Test on target platform
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### Versioning
|
|
||||||
1. Follow semantic versioning: `v{MAJOR}.{MINOR}.{PATCH}`
|
|
||||||
2. Tag releases in git
|
|
||||||
3. Update CHANGELOG.md before tagging
|
|
||||||
4. Include release notes in tag message
|
|
||||||
|
|
||||||
### Testing Before Release
|
|
||||||
```bash
|
|
||||||
# Local testing before release
|
|
||||||
bun run test # Frontend tests
|
|
||||||
bun run test:rust # Rust tests
|
|
||||||
bun run check # Type checking
|
|
||||||
bun run tauri build # Local build test
|
|
||||||
```
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
1. Update [CHANGELOG.md](../../CHANGELOG.md) with changes
|
|
||||||
2. Update [README.md](../../README.md) with new features
|
|
||||||
3. Document breaking changes
|
|
||||||
4. Add migration guide if needed
|
|
||||||
|
|
||||||
## Example Release Workflow
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Update version in relevant files (package.json, Cargo.toml, etc.)
|
|
||||||
vim package.json
|
|
||||||
vim src-tauri/tauri.conf.json
|
|
||||||
|
|
||||||
# 2. Update CHANGELOG
|
|
||||||
vim CHANGELOG.md
|
|
||||||
|
|
||||||
# 3. Commit changes
|
|
||||||
git add .
|
|
||||||
git commit -m "Bump version to v1.0.0"
|
|
||||||
|
|
||||||
# 4. Create annotated tag
|
|
||||||
git tag -a v1.0.0 -m "Release version 1.0.0
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Feature 1
|
|
||||||
- Feature 2
|
|
||||||
|
|
||||||
Fixes:
|
|
||||||
- Fix 1
|
|
||||||
- Fix 2"
|
|
||||||
|
|
||||||
# 5. Push tag to trigger workflow
|
|
||||||
git push origin v1.0.0
|
|
||||||
|
|
||||||
# 6. Monitor workflow in Gitea Actions
|
|
||||||
# Wait for tests → Linux build → Android build → Release
|
|
||||||
|
|
||||||
# 7. Download artifacts and test
|
|
||||||
# Visit release page and verify downloads
|
|
||||||
```
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [Tauri Documentation](https://tauri.app/)
|
|
||||||
- [Semantic Versioning](https://semver.org/)
|
|
||||||
- [GitHub Release Best Practices](https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases)
|
|
||||||
- [Android App Bundle](https://developer.android.com/guide/app-bundle)
|
|
||||||
- [AppImage Documentation](https://docs.appimage.org/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated:** 2026-02-13
|
|
||||||
Vendored
-78
@@ -1,78 +0,0 @@
|
|||||||
# 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.
|
|
||||||
Vendored
-212
@@ -1,212 +0,0 @@
|
|||||||
# CI operations
|
|
||||||
|
|
||||||
How the pipeline is kept working: the builder image, the secrets it needs, the
|
|
||||||
gates that must stay required, and the things that only a human with access to
|
|
||||||
the Gitea instance can do.
|
|
||||||
|
|
||||||
CI is **Gitea Actions** (`.gitea/workflows/`) on `gitea.tourolle.paris`, not
|
|
||||||
GitHub.
|
|
||||||
|
|
||||||
## The workflows
|
|
||||||
|
|
||||||
| Workflow | Trigger | What it protects |
|
|
||||||
|---|---|---|
|
|
||||||
| [build-and-test.yml](../../.gitea/workflows/build-and-test.yml) | push/PR to `master` | Frontend + Rust gates, Android compile check, supply chain |
|
|
||||||
| [traceability-check.yml](../../.gitea/workflows/traceability-check.yml) | push/PR | Requirement coverage ratchet, dangling IDs |
|
|
||||||
| [build-release.yml](../../.gitea/workflows/build-release.yml) | tag `v*` | Builds, signs, publishes, and writes the update manifest |
|
|
||||||
| [publish-docs.yml](../../.gitea/workflows/publish-docs.yml) | push to `master` | Docs site on the `gitea-pages` branch |
|
|
||||||
|
|
||||||
## 🔴 CI installs no system tools
|
|
||||||
|
|
||||||
Every build, test and packaging **tool** lives in the Docker image the job runs
|
|
||||||
in. Never add `apt-get`, `rustup`, `sdkmanager`, or a `curl | tar -xz` of a
|
|
||||||
binary to a workflow step.
|
|
||||||
|
|
||||||
Fetching the project's *own declared dependencies* is not a toolchain install and
|
|
||||||
is fine: `bun install`, cargo pulling crates from the lockfile, `cargo deny`
|
|
||||||
fetching the RustSec advisory database. The distinction is tool versus data.
|
|
||||||
|
|
||||||
This rule has been broken twice, both times invisibly until something else
|
|
||||||
failed. `publish-docs.yml` downloaded mdBook from GitHub releases into
|
|
||||||
`/usr/local/bin` at job time — a hard dependency on GitHub's CDN being up
|
|
||||||
whenever docs were published. Both mdBook and the supply-chain tools are in the
|
|
||||||
image now.
|
|
||||||
|
|
||||||
## The builder image
|
|
||||||
|
|
||||||
`Dockerfile.builder` → `gitea.tourolle.paris/dtourolle/jellytau-builder`.
|
|
||||||
It carries: the pinned Rust toolchain plus rustfmt/clippy and the Android,
|
|
||||||
Windows-MSVC targets; bun and Node; the Android SDK/NDK and a local Gradle
|
|
||||||
distribution; Linux desktop and packaging deps (WebKitGTK, libmpv, rpm, NSIS,
|
|
||||||
cargo-xwin); and the tooling — `cargo-deny`, `cargo-cyclonedx`, `mdbook`.
|
|
||||||
|
|
||||||
Arch packages build in a separate `Dockerfile.arch`, because `makepkg` is
|
|
||||||
Arch-specific.
|
|
||||||
|
|
||||||
### Tags are pinned, and why
|
|
||||||
|
|
||||||
Workflows name an **immutable dated tag** (`:2026.08`), never `:latest`. While
|
|
||||||
every job said `:latest`, rebuilding the image silently changed what every build
|
|
||||||
compiled against — including a rebuild of an old release tag, which is the
|
|
||||||
opposite of reproducible.
|
|
||||||
|
|
||||||
`:latest` is still pushed alongside, for local `docker compose` runs and manual
|
|
||||||
pulls.
|
|
||||||
|
|
||||||
Date tags rather than per-commit SHA tags on purpose: the runner shares a 74 GB
|
|
||||||
disk with two other projects, and SHA-tagged images accumulated there until it
|
|
||||||
filled. Keep a couple of dated tags live and prune the rest.
|
|
||||||
|
|
||||||
### Changing the image
|
|
||||||
|
|
||||||
The order matters — CI breaks if the workflow lands before the image exists.
|
|
||||||
|
|
||||||
A caveat learned the hard way: the *trailing* layer is only fast for `cargo
|
|
||||||
install` tools. Adding an **apt** package invalidates the packaging layer, which
|
|
||||||
sits above the `cargo-xwin`/`cargo-deny` installs, so those recompile too — a
|
|
||||||
~20 minute rebuild rather than ~2.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Edit Dockerfile.builder. Put new tools in the TRAILING layer: it exists so
|
|
||||||
# a tool change is a ~2 min rebuild instead of ~15.
|
|
||||||
# 2. Build and push, tagged with the new month:
|
|
||||||
./scripts/build-builder-image.sh 2026.09
|
|
||||||
# 3. Repoint every workflow at the new tag, in the same commit as whatever
|
|
||||||
# needed the new tool:
|
|
||||||
sed -i 's|jellytau-builder:2026.08|jellytau-builder:2026.09|g' .gitea/workflows/*.yml
|
|
||||||
# 4. Verify the tools are actually in it:
|
|
||||||
docker run --rm gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09 \
|
|
||||||
-c "cargo deny --version; mdbook --version"
|
|
||||||
```
|
|
||||||
|
|
||||||
🔴 The Rust version is pinned in **two** places that must agree:
|
|
||||||
`RUST_VERSION` in `Dockerfile.builder` and `channel` in
|
|
||||||
`src-tauri/rust-toolchain.toml`. If they drift, rustup downloads the pinned
|
|
||||||
toolchain inside the job — a toolchain install in CI. Bump both, rebuild, push,
|
|
||||||
then merge.
|
|
||||||
|
|
||||||
## Tauri plugin versions are pinned in pairs
|
|
||||||
|
|
||||||
Every Tauri plugin exists twice: a Rust crate in `src-tauri/Cargo.toml` and an
|
|
||||||
npm package in `package.json`. **The Tauri CLI refuses to build when the two are
|
|
||||||
on different minor versions** — not a warning, a hard stop before compilation.
|
|
||||||
|
|
||||||
Both sides are therefore pinned *exactly* (`"2.8.0"`, not `"^2.8.0"`). A caret
|
|
||||||
range is what let them drift apart in the first place: `bun add` took the latest
|
|
||||||
npm package while cargo held an older crate, and nothing noticed until a release
|
|
||||||
build refused to start.
|
|
||||||
|
|
||||||
Nothing in `build-and-test.yml` runs `tauri build` — that happens only on a tag —
|
|
||||||
so this class of breakage used to be invisible until release day. The
|
|
||||||
`Check Tauri plugin versions match` step runs `tauri info`, which performs the
|
|
||||||
same comparison without building.
|
|
||||||
|
|
||||||
To upgrade a plugin, move **both** sides together and re-run that step. Expect
|
|
||||||
the Rust side to be the constraint: a newer plugin crate may pull a large
|
|
||||||
transitive upgrade (bumping `tauri-plugin-log` to 2.9.0 also moved `wry`,
|
|
||||||
`wasm-bindgen`, `web-sys` and `webkit2gtk`), which touches the webview and
|
|
||||||
therefore video playback. That is a change to make deliberately, with a full
|
|
||||||
build and a playback check — not one to slip into a release.
|
|
||||||
|
|
||||||
## AppImage needs more than the Rust toolchain
|
|
||||||
|
|
||||||
`linuxdeploy` (which Tauri downloads at build time to assemble the AppImage)
|
|
||||||
shells out to distro tools that a minimal server image does not have. It aborts
|
|
||||||
the whole bundle on the first one missing:
|
|
||||||
|
|
||||||
```
|
|
||||||
failed to bundle project: xdg-open binary not found
|
|
||||||
```
|
|
||||||
|
|
||||||
The image therefore carries `xdg-utils`, `desktop-file-utils` and `zsync`. This
|
|
||||||
is a class of failure that **cannot be caught by building locally**: a developer
|
|
||||||
machine is a desktop and has all three, so the AppImage builds there and fails in
|
|
||||||
CI. It cost one release build to find.
|
|
||||||
|
|
||||||
Tauri's AppImage bundler also downloads `linuxdeploy`, `AppRun` and two plugin
|
|
||||||
scripts from GitHub during the build. That is Tauri's behaviour, not ours, but it
|
|
||||||
means an AppImage build depends on GitHub being reachable from the runner.
|
|
||||||
|
|
||||||
## Secrets
|
|
||||||
|
|
||||||
Managed with the `tea` CLI (`tea actions secrets list`) or the repo settings UI.
|
|
||||||
|
|
||||||
| Secret | Used by | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `ANDROID_KEYSTORE_BASE64` | release | Base64 of the release keystore |
|
|
||||||
| `ANDROID_KEYSTORE_PASSWORD` | release | |
|
|
||||||
| `ANDROID_KEY_ALIAS` | release | |
|
|
||||||
| `ANDROID_KEY_PASSWORD` | release | |
|
|
||||||
| `TAURI_SIGNING_PRIVATE_KEY` | release | minisign key for the desktop updater |
|
|
||||||
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | release | |
|
|
||||||
| `GITEA_TOKEN` | release, docs | PAT; falls back to the auto-provided token |
|
|
||||||
|
|
||||||
The updater keypair's public half is committed in `src-tauri/tauri.conf.json` —
|
|
||||||
that one is meant to be public; it is what clients verify against. The private
|
|
||||||
half exists in the Gitea secret and in the maintainer's local `.env` (which is
|
|
||||||
gitignored) and at `~/.tauri/jellytau.key`.
|
|
||||||
|
|
||||||
**Losing the private key means losing the ability to ship updates to installed
|
|
||||||
desktop clients**, because they will only accept payloads signed by the key
|
|
||||||
matching the public key they were built with. Recovering means generating a new
|
|
||||||
pair, shipping a build carrying the new public key, and telling everyone on an
|
|
||||||
older build to reinstall by hand. Back it up.
|
|
||||||
|
|
||||||
## Required status checks
|
|
||||||
|
|
||||||
Gitea → repo Settings → Branches → protect `master`, requiring:
|
|
||||||
|
|
||||||
- `Run Tests`
|
|
||||||
- `Android Compile Check`
|
|
||||||
- `Supply Chain`
|
|
||||||
- the traceability job
|
|
||||||
|
|
||||||
Without branch protection, every gate in this document is advisory: a push
|
|
||||||
straight to `master` lands whether or not CI is red. That is the state the repo
|
|
||||||
was in for its whole history before this was set up.
|
|
||||||
|
|
||||||
## The runner
|
|
||||||
|
|
||||||
One self-hosted runner, one ~74 GB disk shared with two other projects. It fills,
|
|
||||||
and when it does the symptoms are misleading: cargo dying mid-link, docker
|
|
||||||
refusing to pull, `actions/cache` quietly not saving — anything except an obvious
|
|
||||||
out-of-space error. Check the disk first.
|
|
||||||
|
|
||||||
There is deliberately no scheduled job watching this. On a single-slot runner a
|
|
||||||
daily job occupies the slot and pulls the builder image to run `df`, and `df`
|
|
||||||
inside a container does not reliably describe the host's disk anyway — it would
|
|
||||||
cost real build capacity to report a number that might be wrong. Check it by hand
|
|
||||||
on the runner:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
df -h /
|
|
||||||
docker system df -v
|
|
||||||
```
|
|
||||||
|
|
||||||
When it does fill:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker image prune -a
|
|
||||||
docker volume prune -a # the -a matters: without it, NAMED volumes are kept,
|
|
||||||
# which is exactly how this filled up unnoticed
|
|
||||||
```
|
|
||||||
|
|
||||||
Never cache `src-tauri/target` — it is ~16 GB, and caching it under several keys
|
|
||||||
is what filled the disk at ~1.15 GB/day. The workflows cache only the cargo
|
|
||||||
registry index and `.crate` tarballs; cargo re-extracts `registry/src` for free.
|
|
||||||
|
|
||||||
## Release verification
|
|
||||||
|
|
||||||
The steps that catch a broken release before users do are in
|
|
||||||
[release-checklist.md](../release-checklist.md) — in particular the update path:
|
|
||||||
`latest.json` must be live on the `updater` branch, both platform entries must
|
|
||||||
carry a non-empty signature, and the previous release should be installed and
|
|
||||||
asked to update to the new one.
|
|
||||||
|
|
||||||
## Bus factor
|
|
||||||
|
|
||||||
The Gitea instance holds the canonical remote, the signing secrets, the container
|
|
||||||
registry and the CI runner. **It is not backed up as part of this repository, and
|
|
||||||
nothing in this repository can restore it.** That is the largest single risk to
|
|
||||||
the project — larger than any gate in this document — and the backup lives
|
|
||||||
outside it.
|
|
||||||
Vendored
-282
@@ -1,282 +0,0 @@
|
|||||||
# Docker & CI/CD Setup for JellyTau
|
|
||||||
|
|
||||||
This document explains how to use the Docker configuration and Gitea Act CI/CD pipeline for building and testing JellyTau.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The setup includes:
|
|
||||||
- **Dockerfile.builder**: Pre-built image with all dependencies (push to your registry)
|
|
||||||
- **Dockerfile**: Multi-stage build for local testing and building
|
|
||||||
- **docker-compose.yml**: Orchestration for local development and testing
|
|
||||||
- **.gitea/workflows/build-and-test.yml**: Automated CI/CD pipeline using pre-built builder image
|
|
||||||
|
|
||||||
### Quick Start
|
|
||||||
|
|
||||||
**For CI/CD (Gitea Actions)**:
|
|
||||||
1. Build and push builder image (see [build-builder-image.md](build-builder-image.md))
|
|
||||||
2. Push to master branch - workflow runs automatically
|
|
||||||
3. Check Actions tab for results and APK artifacts
|
|
||||||
|
|
||||||
**For Local Testing**:
|
|
||||||
```bash
|
|
||||||
docker-compose run test # Run tests
|
|
||||||
docker-compose run android-build # Build APK
|
|
||||||
docker-compose run dev # Interactive shell
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker Usage
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Docker Engine 20.10+
|
|
||||||
- Docker Compose 2.0+ (if using docker-compose)
|
|
||||||
- At least 10GB free disk space (for Android SDK and build artifacts)
|
|
||||||
|
|
||||||
### Building the Docker Image
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build the complete image
|
|
||||||
docker build -t jellytau:latest .
|
|
||||||
|
|
||||||
# Build specific target
|
|
||||||
docker build -t jellytau:test --target test .
|
|
||||||
docker build -t jellytau:android --target android-build .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using Docker Compose
|
|
||||||
|
|
||||||
#### Run Tests Only
|
|
||||||
```bash
|
|
||||||
docker-compose run test
|
|
||||||
```
|
|
||||||
|
|
||||||
This will:
|
|
||||||
1. Install all dependencies
|
|
||||||
2. Run frontend tests (Vitest)
|
|
||||||
3. Run Rust backend tests
|
|
||||||
4. Report results
|
|
||||||
|
|
||||||
#### Build Android APK
|
|
||||||
```bash
|
|
||||||
docker-compose run android-build
|
|
||||||
```
|
|
||||||
|
|
||||||
This will:
|
|
||||||
1. Run tests first (depends on test service)
|
|
||||||
2. If tests pass, build the Android APK
|
|
||||||
3. Output APK files to `src-tauri/gen/android/app/build/outputs/apk/`
|
|
||||||
|
|
||||||
#### Interactive Development
|
|
||||||
```bash
|
|
||||||
docker-compose run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
This starts an interactive shell with all development tools available. From here you can:
|
|
||||||
```bash
|
|
||||||
bun install
|
|
||||||
bun run build
|
|
||||||
bun test
|
|
||||||
bun run tauri android build --apk true
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Run All Services in Sequence
|
|
||||||
```bash
|
|
||||||
docker-compose up --abort-on-container-exit
|
|
||||||
```
|
|
||||||
|
|
||||||
### Extracting Build Artifacts
|
|
||||||
|
|
||||||
After a successful build, APK files are located in:
|
|
||||||
```
|
|
||||||
src-tauri/gen/android/app/build/outputs/apk/
|
|
||||||
```
|
|
||||||
|
|
||||||
Copy to your host machine:
|
|
||||||
```bash
|
|
||||||
docker cp jellytau-android-build:/app/src-tauri/gen/android/app/build/outputs/apk ./apk-output
|
|
||||||
```
|
|
||||||
|
|
||||||
## Gitea Act CI/CD Pipeline
|
|
||||||
|
|
||||||
The `.gitea/workflows/build-and-test.yml` workflow automates:
|
|
||||||
|
|
||||||
**Single Job**: Runs on every push to `master` and PRs
|
|
||||||
- Uses pre-built builder image (no setup time)
|
|
||||||
- Installs project dependencies
|
|
||||||
- Runs frontend tests (Vitest)
|
|
||||||
- Runs Rust backend tests
|
|
||||||
- Builds the frontend
|
|
||||||
- Builds the Android APK
|
|
||||||
- Uploads APK as artifact (30-day retention)
|
|
||||||
|
|
||||||
The workflow skips markdown files to avoid unnecessary builds.
|
|
||||||
|
|
||||||
### Workflow Triggers
|
|
||||||
|
|
||||||
The workflow runs on:
|
|
||||||
- Push to `master` or `main` branches
|
|
||||||
- Pull requests to `master` or `main` branches
|
|
||||||
- Can be extended with: `workflow_dispatch` for manual triggers
|
|
||||||
|
|
||||||
### Setting Up the Builder Image
|
|
||||||
|
|
||||||
Before using the CI/CD pipeline, you must build and push the builder image:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build the image
|
|
||||||
docker build -f Dockerfile.builder -t jellytau-builder:latest .
|
|
||||||
|
|
||||||
# Tag for your registry
|
|
||||||
docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
|
|
||||||
# Push to registry
|
|
||||||
docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
See [build-builder-image.md](build-builder-image.md) for detailed instructions.
|
|
||||||
|
|
||||||
### Setting Up Gitea Act
|
|
||||||
|
|
||||||
1. **Ensure builder image is pushed** (see above)
|
|
||||||
|
|
||||||
2. **Push to Gitea repository**:
|
|
||||||
The workflow will automatically trigger on push to `master` or pull requests
|
|
||||||
|
|
||||||
3. **View workflow runs in Gitea UI**:
|
|
||||||
- Navigate to your repository
|
|
||||||
- Go to Actions tab
|
|
||||||
- Click on workflow runs to see logs
|
|
||||||
|
|
||||||
4. **Test locally** (optional):
|
|
||||||
```bash
|
|
||||||
# Install act if needed
|
|
||||||
curl https://gitea.com/actions/setup-act/releases/download/v0.25.0/act-0.25.0-linux-x86_64.tar.gz | tar xz
|
|
||||||
|
|
||||||
# Run locally (requires builder image to be available)
|
|
||||||
./act push --file .gitea/workflows/build-and-test.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
### Customizing the Workflow
|
|
||||||
|
|
||||||
#### Modify Build Triggers
|
|
||||||
Edit `.gitea/workflows/build-and-test.yml` to change when builds run:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
- develop # Add more branches
|
|
||||||
paths:
|
|
||||||
- 'src/**' # Only run if src/ changes
|
|
||||||
- 'src-tauri/**' # Only run if Rust code changes
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Add Notifications
|
|
||||||
Add Slack, Discord, or email notifications on build completion:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Notify on success
|
|
||||||
if: success()
|
|
||||||
run: |
|
|
||||||
curl -X POST https://slack-webhook-url...
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Customize APK Upload
|
|
||||||
Modify artifact retention or add to cloud storage:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Upload APK to S3
|
|
||||||
uses: actions/s3-sync@v1
|
|
||||||
with:
|
|
||||||
aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY }}
|
|
||||||
aws_secret_access_key: ${{ secrets.AWS_SECRET_KEY }}
|
|
||||||
aws_bucket: my-apk-bucket
|
|
||||||
source_dir: src-tauri/gen/android/app/build/outputs/apk/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Environment Setup in CI
|
|
||||||
|
|
||||||
### Secret Variables
|
|
||||||
To use secrets in the workflow, set them in Gitea:
|
|
||||||
|
|
||||||
1. Go to Repository Settings → Secrets
|
|
||||||
2. Add secrets like:
|
|
||||||
- `AWS_ACCESS_KEY` for S3 uploads
|
|
||||||
- `SLACK_WEBHOOK_URL` for notifications
|
|
||||||
- `GITHUB_TOKEN` for releases (pre-configured)
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Out of Memory During Build
|
|
||||||
Android builds are memory-intensive. If you get OOM errors:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Limit memory in docker-compose
|
|
||||||
services:
|
|
||||||
android-build:
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 6G
|
|
||||||
```
|
|
||||||
|
|
||||||
Or increase Docker's memory allocation in Docker Desktop settings.
|
|
||||||
|
|
||||||
### Android SDK Download Timeout
|
|
||||||
If downloads timeout, increase timeout or download manually:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# In container, with longer timeout
|
|
||||||
timeout 600 sdkmanager --sdk_root=$ANDROID_HOME ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rust Compilation Errors
|
|
||||||
Make sure Rust is updated:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rustup update
|
|
||||||
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cache Issues
|
|
||||||
Clear Docker cache and rebuild:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose down -v # Remove volumes
|
|
||||||
docker system prune # Clean up dangling images
|
|
||||||
docker-compose up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Tips
|
|
||||||
|
|
||||||
1. **Cache Reuse**: Both Docker and Gitea Act cache dependencies across runs
|
|
||||||
2. **Parallel Steps**: The workflow runs frontend and Rust tests in series; consider parallelizing for faster CI
|
|
||||||
3. **Incremental Builds**: Rust and Node caches persist between runs
|
|
||||||
4. **Docker Buildkit**: Enable for faster builds:
|
|
||||||
```bash
|
|
||||||
DOCKER_BUILDKIT=1 docker build .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
- Dockerfile uses `ubuntu:24.04` base image from official Docker Hub
|
|
||||||
- NDK is downloaded from official Google servers (verified via HTTPS)
|
|
||||||
- No credentials are stored in the Dockerfile
|
|
||||||
- Use Gitea Secrets for sensitive values (API keys, tokens, etc.)
|
|
||||||
- Lock dependency versions in `Cargo.toml` and `package.json`
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. Test locally with `docker-compose up`
|
|
||||||
2. Push to your Gitea repository
|
|
||||||
3. Monitor workflow runs in the Actions tab
|
|
||||||
4. Configure secrets in repository settings for production builds
|
|
||||||
5. Set up artifact retention policies (currently 30 days)
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [Gitea Actions Documentation](https://docs.gitea.io/en-us/actions/)
|
|
||||||
- [Docker Multi-stage Builds](https://docs.docker.com/build/building/multi-stage/)
|
|
||||||
- [Android Build Tools](https://developer.android.com/studio/command-line)
|
|
||||||
- [Tauri Android Guide](https://tauri.app/v1/guides/building/android)
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
# Defect windows — which bugs were present when
|
|
||||||
|
|
||||||
For each fixed defect, the releases it was actually present in. Companion to
|
|
||||||
[CHANGELOG.md](../CHANGELOG.md), which says what changed; this says how long each
|
|
||||||
fault had been shipping before it did.
|
|
||||||
|
|
||||||
**"Present since"** is the first *release* containing the defective code, not the
|
|
||||||
first release where a user could hit it — those differ, sometimes by months, and
|
|
||||||
the gap is called out where it matters. **"How dated"** records the evidence, so a
|
|
||||||
row can be re-checked or disputed:
|
|
||||||
|
|
||||||
| Method | Meaning |
|
|
||||||
|--------|---------|
|
|
||||||
| `pickaxe` | `git log -S<token>` on the defective token — the commit that introduced the exact string, then the earliest tag containing it. Strongest evidence. |
|
|
||||||
| `feature` | The defect is inseparable from a feature that landed whole (bad rung in a new algorithm, missing caller in new plumbing), dated to that feature's release. |
|
|
||||||
| `absence` | The fix *adds* something that was never there. Dated to when the surrounding code was built, since there is no introducing commit to find. Weakest — treat as "no later than". |
|
|
||||||
|
|
||||||
## Present since the first release
|
|
||||||
|
|
||||||
Eighteen defects date to the initial proof of concept (v0.0.1, 2026-06-23) and
|
|
||||||
shipped for between two weeks and three months before anyone hit them.
|
|
||||||
That is the dominant pattern here: not regressions, but original assumptions that
|
|
||||||
went unexercised until a later feature leaned on them.
|
|
||||||
|
|
||||||
DR-265 is the clearest example of the "present since" / "reachable since" gap
|
|
||||||
this file warns about: the `!isPlaying` gate has been there since the first
|
|
||||||
commit, but nothing paused the document underneath a playing `<video>` until PiP
|
|
||||||
started working on the HTML5 path in v0.5.3. Defective for ~9 weeks, hittable
|
|
||||||
for ~2.
|
|
||||||
|
|
||||||
| Defect | Present since | Fixed in | Shipped broken for | How dated |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `AudioStreamIndex=0` pinned the video stream as the audio track (DR-140) | v0.0.1 | **v0.4.6** | ~7 weeks | pickaxe |
|
|
||||||
| Download URL spelled `videoBitrate`, which Jellyfin does not bind (DR-123) | v0.0.1 | **v0.5.1** | ~7 weeks | pickaxe |
|
|
||||||
| `pause_download` / `resume_download` were no-ops (DR-168) | v0.0.1 | **v0.5.3** | ~7.5 weeks | pickaxe |
|
|
||||||
| `.part` sidecar named by `with_extension`, so no cleanup path matched it (DR-169) | v0.0.1 | **v0.5.3** | ~7.5 weeks | pickaxe |
|
|
||||||
| `Range` sent on every retry regardless of the response (DR-170) | v0.0.1 | **v0.5.3** | ~7.5 weeks | pickaxe |
|
|
||||||
| `/Items/Latest` requested with the default `GroupItems=false` | v0.0.1 | **v0.5.1** | ~7 weeks | pickaxe |
|
|
||||||
| `SubtitleStreamIndex` omitted from PlaybackInfo, letting the server burn in (DR-176) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
|
|
||||||
| No `PlaySessionId`, and one hardcoded `DeviceId`, on every stream URL (DR-177) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
|
|
||||||
| `download_item` never recorded `media_type`; NULL read as `'audio'` (DR-135) | v0.0.1 | **v0.4.6** | ~7 weeks | pickaxe |
|
|
||||||
| `download_album` read its track list from the local cache (DR-173) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
|
|
||||||
|
|
||||||
| Device profile carried no `MaxAudioChannels` (DR-141) | v0.0.1 | **v0.4.6** | ~7 weeks | absence |
|
|
||||||
| Streaming ceiling fixed at 20 Mbps with no way to lower it (UR-074) | v0.0.1 | **v0.5.3** (as a feature) | ~7.5 weeks | pickaxe |
|
|
||||||
| Hero banner auto-rotation never restarted after a manual swipe (DR-038) | v0.0.1 | **v0.9.1** | ~8.5 weeks | pickaxe |
|
|
||||||
| Audio-track change asked the player to select a track the transcode never carried (DR-258) | v0.0.1 | **v0.11.1** | ~2 months | pickaxe |
|
|
||||||
| Subtitle URL missing its `Stream.` route segment, so every fetch 404ed (DR-259) | v0.0.1 | **v0.11.1** | ~2 months | pickaxe |
|
|
||||||
| `timeupdate` gated on `!isPlaying`, so a paused activity froze the position (DR-265) | v0.0.1 | **v0.11.5** | ~9 weeks | pickaxe |
|
|
||||||
| Download client used a 5-minute *total* request deadline, so any transfer longer than that was cut off and retried (DR-289) | v0.0.1 | **v0.12.2** | ~13 weeks | pickaxe |
|
|
||||||
| Progress reported 0.0 for any response without `Content-Length` — every transcode download (DR-290) | v0.0.1 | **v0.12.2** | ~13 weeks | absence |
|
|
||||||
|
|
||||||
The two v0.12.2 rows are the same latent shape as the `Range` header above:
|
|
||||||
the deadline was inert while every download was a short direct copy, and
|
|
||||||
became fatal only once DR-171 (v0.5.3) started re-encoding audio for offline
|
|
||||||
playback — a transcode is both slow enough to exceed five minutes and
|
|
||||||
impossible to resume. Defective for ~13 weeks, hittable for ~5.
|
|
||||||
|
|
||||||
### Why they took so long to surface
|
|
||||||
|
|
||||||
Four of these were **latent until a later feature exercised them**, which is why
|
|
||||||
the fix lands so far from the cause:
|
|
||||||
|
|
||||||
- The `videoBitrate` casing was harmless while every download was `original`. It
|
|
||||||
became visible only once a quality picker existed to select against — and then
|
|
||||||
produced no error, just a full-size file, because Jellyfin discards an unbound
|
|
||||||
query key silently.
|
|
||||||
- The unconditional `Range` header was inert for the same reason: `original` is
|
|
||||||
the one rung served with a `Content-Length` and real byte-range support. It
|
|
||||||
started corrupting files in **v0.5.1**, the moment the casing fix made
|
|
||||||
transcoded downloads actually transcode. So the *code* dates to v0.0.1 and the
|
|
||||||
*corruption* to v0.5.1 — a one-release window for the visible symptom.
|
|
||||||
- The missing `PlaySessionId` only bites when a stream is re-opened for the same
|
|
||||||
item. Nothing re-opened one until quality switching, transcoded seek and
|
|
||||||
audio-track switching existed.
|
|
||||||
- The omitted `SubtitleStreamIndex` only bites on sources whose own default
|
|
||||||
subtitle track is image-based, since that is what forces the server from
|
|
||||||
sidecar to burn-in.
|
|
||||||
|
|
||||||
Two were **masked by soft failure**: the asset protocol being disabled (DR-134)
|
|
||||||
was hidden by the thumbnail cache falling back to the server copy whenever the
|
|
||||||
server was reachable, and `AudioStreamIndex=0` was hidden by servers that
|
|
||||||
silently correct an out-of-range index — which is exactly why it was reported as
|
|
||||||
"*some* videos have no audio" rather than as a bug in the client.
|
|
||||||
|
|
||||||
## Introduced by a feature, fixed later
|
|
||||||
|
|
||||||
| Defect | Present since | Fixed in | How dated |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Native-path resume position never applied (both layers assumed the other seeked) | v0.0.9/v0.0.10 | **v0.5.1** | feature (`PlayerAdapter` contract) |
|
|
||||||
| `get_downloaded_items` matched "this library exists" rather than constraining the item to it (DR-167) | v0.0.17 | **v0.5.3** | feature (browsable downloaded library) |
|
|
||||||
| `SCOPE_ITEM_TYPES` — the frontend/backend boundary leak (DR-063) | v0.0.17 | **v0.2.1** | pickaxe |
|
|
||||||
| `check:boundary` anchored to the query site, blind to a named const (DR-094) | v0.0.17 | **v0.2.1** | feature (tripwire landed with the leak it missed) |
|
|
||||||
| Coverage gate divided by hardcoded denominators, reporting 158% (DR-093) | v0.0.1 | **v0.2.1** | pickaxe |
|
|
||||||
| Tap deferral raced the WebView's synthesized click (DR-092 → DR-098) | v0.1.5 | **v0.2.7** | feature (the deferral itself) |
|
|
||||||
| Transport for webview media decided from `el.paused` in the DOM (DR-097) | v0.0.9/v0.0.10 | **v0.2.7** | feature (`Html5PlayerAdapter`) |
|
|
||||||
| `pick_current_episode` rung 3 returned the first *gap*, not the furthest watched | v0.3.0 | **v0.5.1** | feature |
|
|
||||||
| `mirror_user_data` mirrored `is_favorite` alone and returned early (DR-155) | v0.4.0 | **v0.5.1** | pickaxe |
|
|
||||||
| Stop-report path never fed the sync queue that existed for it (DR-154) | v0.4.6 | **v0.5.1** | feature (queue + drain landed with no producer) |
|
|
||||||
| Background-audio base applied in two display-only places (DR-159) | v0.2.9 | **v0.5.3** | pickaxe |
|
|
||||||
| Positions reported as 0 before the first tick, and always 0 for webview media (DR-178/179/180) | v0.5.3 | **v0.5.5** | feature (DR-159's tick boundary) |
|
|
||||||
| Length-less handoff transcode left to the player's own load-error retry, which can only restart it (DR-203) | v0.0.16 | **v0.8.2** | feature (the handoff's progressive-mp3 choice) |
|
|
||||||
| Recently Added trusted the server to group new tracks — `GroupItems=true` only groups a track whose parent chain resolves a `MusicAlbum`, and older servers ignore it | v0.5.1 | **v0.11.4** | feature (the v0.5.1 fix for the same symptom) |
|
|
||||||
| PiP and the background-audio handoff both armable, decided by one `isInPictureInPictureMode` sample (DR-266) | v0.5.3 | **v0.11.5** | feature (PiP on the HTML5 path, beside a toggle that had shipped in v0.0.16) |
|
|
||||||
|
|
||||||
Three of these are worth separating out, because the defect is not a mistake in
|
|
||||||
the code so much as **plumbing that was built and never connected**:
|
|
||||||
|
|
||||||
- `repository_get_next_up_episodes` accepted a `series_id` from the day it was
|
|
||||||
written, and no caller passed one until v0.3.0.
|
|
||||||
- The sync queue and its drain were built, tested and running in v0.4.6 with
|
|
||||||
neither of its two would-be producers ever called.
|
|
||||||
- Both halves of the watched-state backend existed with no caller before v0.5.3.
|
|
||||||
|
|
||||||
An automated check cannot see any of these — the code is present, tested and
|
|
||||||
reachable in principle. Only tracing a requirement to a *call site* catches it.
|
|
||||||
|
|
||||||
## Short windows (one release or less)
|
|
||||||
|
|
||||||
| Defect | Present since | Fixed in | Note |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `experimentalNativeVideo` defaulted on, shipping audio with a blank screen (DR-161 → DR-172) | v0.5.3 | **v0.5.4** | One release. The decode path was fine; the compositing step never ran. |
|
|
||||||
| Webview-shaped audio profile insufficient — server ignores a profile's audio codec (DR-149) | v0.4.7 | **v0.4.8** | The v0.4.7 fix for DR-148 was necessary and not sufficient. |
|
|
||||||
| Android `versionCode` floor went stale (`minor*100` yielding less than the 5002 already in the field) | v0.5.0 | **v0.5.3** | Caught before a broken APK shipped; no released build was un-installable. |
|
|
||||||
| Subtitle sidecar work reverted by a commit assembled from a stale tree | v0.5.5 | **v0.5.5** | Never released broken — both commits are in v0.5.5. |
|
|
||||||
|
|
||||||
## Fixed twice / never actually broken
|
|
||||||
|
|
||||||
- **Autoplay time reset (v0.0.2).** Two commit objects carry this identical
|
|
||||||
change: `dcf08f30` (merged via Gitea PR #3, tagged v0.0.2) and `fa7cb6e9` (the
|
|
||||||
local original). Both have the same parent `674c8e5c` and the same diff. A merge
|
|
||||||
chain pulled `fa7cb6e9` and its follow-up `1e599627` into master's history
|
|
||||||
during v0.5.5, so `git log v0.5.4..v0.5.5` lists an autoplay fix that changed no
|
|
||||||
file in that release — `nextEpisodeService.ts` is byte-identical across the tag
|
|
||||||
boundary. The fix shipped in **v0.0.2** and has not regressed.
|
|
||||||
|
|
||||||
This is the one case where reading the changelog off `git log` subjects would
|
|
||||||
have produced a false entry, and it is a good argument for the project's
|
|
||||||
practice of deriving release notes from TRACES rather than commit subjects.
|
|
||||||
|
|
||||||
## Recurring shapes
|
|
||||||
|
|
||||||
Four causes account for most of the table:
|
|
||||||
|
|
||||||
1. **An omitted parameter is not a neutral default.** `SubtitleStreamIndex`,
|
|
||||||
`AudioStreamIndex`, `GroupItems` and `MaxAudioChannels` all had a server-side
|
|
||||||
default that was actively wrong, and in three of the four the server's choice
|
|
||||||
was more expensive than the one intended — burn-in forcing a full re-encode
|
|
||||||
being the extreme case.
|
|
||||||
2. **Silent binding failures.** `videoBitRate` produced no error, no warning and a
|
|
||||||
plausible-looking file. So did an unbound `Range`, and so did the coverage gate
|
|
||||||
dividing by a stale denominator.
|
|
||||||
3. **Two layers each assuming the other acts.** Native resume (adapter recorded
|
|
||||||
the position, backend never seeked), end-of-playback dispatch (two paths, one
|
|
||||||
unreachable), and the surface/attach split in v0.5.0's native video.
|
|
||||||
4. **A guard keyed on state that moves.** The tap deferral keyed suppression on a
|
|
||||||
timer handle the callback had already cleared; the HTML5 toggle keyed
|
|
||||||
play-vs-pause on `el.paused`, which flips while buffering.
|
|
||||||
|
|
||||||
## Reproducing this
|
|
||||||
|
|
||||||
The pickaxe rows can be re-derived directly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git log --oneline --reverse -S'<defective token>' -- src-tauri/src # introducing commit
|
|
||||||
git tag --contains <sha> | sort -V | head -1 # first release with it
|
|
||||||
```
|
|
||||||
|
|
||||||
Blaming the lines a fix removed (`git blame` at the fix's parent) is faster to run
|
|
||||||
across many commits but was **not** used for the rows above: it reliably lands on
|
|
||||||
whichever commit last touched the adjacent lines, which is usually not the commit
|
|
||||||
that introduced the defect. It was used only to shortlist candidates.
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
# Native player — verification plan
|
|
||||||
|
|
||||||
What to check before the `MediaPlayer` contract and Linux native video reach
|
|
||||||
`master`.
|
|
||||||
|
|
||||||
This is not a generic smoke test. Every case below exists because something
|
|
||||||
specific went wrong, and most of them were found on hardware **after** the
|
|
||||||
automated suites were green. Treat the sequences as load-bearing: several
|
|
||||||
defects only appeared in a particular order of actions, and testing the same
|
|
||||||
features in a different order missed them entirely.
|
|
||||||
|
|
||||||
Companion to [release-checklist.md](release-checklist.md), which covers the
|
|
||||||
release mechanics. This covers whether the player is fit to release at all.
|
|
||||||
|
|
||||||
## What is risky about this change
|
|
||||||
|
|
||||||
- `PlayerController` now talks to a `MediaPlayer` contract instead of
|
|
||||||
`PlayerBackend`. Every engine reaches it through an adapter that did not exist
|
|
||||||
before (DR-245).
|
|
||||||
- mpv decodes video on Linux for the first time, composited under the webview
|
|
||||||
(DR-231).
|
|
||||||
- Seek strategy is driven by an ability each engine declares rather than by a
|
|
||||||
truth table (DR-246).
|
|
||||||
- Two regressions were introduced during this work and caught only on a device:
|
|
||||||
a wrong capability for ExoPlayer (DR-246 follow-up) and a `Duration` panic
|
|
||||||
(DR-252). Both were invisible to the test suites.
|
|
||||||
|
|
||||||
The suites originally verified only engines that *behave*, which is why both
|
|
||||||
regressions passed them. That gap is now partly closed in code rather than in
|
|
||||||
this document: `UT-223` drives a deliberately hostile engine — `C.TIME_UNSET`,
|
|
||||||
NaN, infinities, negatives — through the adapter, and fails with the exact
|
|
||||||
panic that produced a black screen on a tablet. `UT-224` pins the handoff
|
|
||||||
clearing that was previously verified by listening to a device.
|
|
||||||
|
|
||||||
**Prefer moving cases out of this file and into tests.** Anything here that
|
|
||||||
could fail automatically should; a checklist depends on someone remembering to
|
|
||||||
follow it, and the two defects it was written for cost hardware time that would
|
|
||||||
have been better spent making the suites realistic. What is left below is what
|
|
||||||
genuinely needs eyes, ears, or a display — not what merely has not been
|
|
||||||
automated yet.
|
|
||||||
|
|
||||||
## 1. Automated gates
|
|
||||||
|
|
||||||
Cheap, fast, and non-negotiable. Run from the worktree.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run check # 0 errors, 0 warnings
|
|
||||||
bun run test # frontend
|
|
||||||
bun run test:rust # Rust
|
|
||||||
bun run format:check
|
|
||||||
bun run lint # 0 errors; warnings at or below the CI ratchet
|
|
||||||
bun run check:boundary
|
|
||||||
bun run traces:validate
|
|
||||||
bun run traces:coverage # at or above MIN_THRESHOLD
|
|
||||||
cd src-tauri && cargo fmt --check && cargo clippy --all-targets -- -D warnings
|
|
||||||
cargo clippy --all-targets --features conformance -- -D warnings
|
|
||||||
```
|
|
||||||
|
|
||||||
The eslint warning count is a **ratchet**: equal to the CI limit is a pass, one
|
|
||||||
over fails the build. Going one over is how a piece of dead state was found
|
|
||||||
during this work — do not raise the limit to get past it.
|
|
||||||
|
|
||||||
## 2. Engine conformance
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run test:player # mpv + legacy, desktop
|
|
||||||
bun run test:player:android # ExoPlayer, on a connected device
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected, and each deviation is meaningful rather than noise:
|
|
||||||
|
|
||||||
| Engine | Result | If it differs |
|
|
||||||
|---|---|---|
|
|
||||||
| `MpvPlayer` | 9/9 | A real regression. Stop. |
|
|
||||||
| `LegacyPlayer` | 8/9 | The one failure is `transport_settings_round_trip`: the old trait has no mute or rate. Any *other* failure is a regression. |
|
|
||||||
| ExoPlayer (device) | 7/7 | Two cases are absent because the Kotlin player exposes no mute or rate. |
|
|
||||||
|
|
||||||
A green conformance run is **not** sufficient evidence to ship. Both regressions
|
|
||||||
introduced during this work passed conformance.
|
|
||||||
|
|
||||||
## 3. Desktop (Linux)
|
|
||||||
|
|
||||||
Run with native video on, since that is what is new:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Direct play** — a file the server does not transcode. Picture and sound.
|
|
||||||
- [ ] **Transcoded play** — something the server must re-encode (4K, HEVC, or an
|
|
||||||
audio codec the renderer cannot take).
|
|
||||||
- [ ] **Resume** — an item watched previously *on this install*. The prompt
|
|
||||||
appears and playback starts at the offered position, not at zero.
|
|
||||||
*(Resume is device-local — see "Known open".)*
|
|
||||||
- [ ] **Scrub** on a direct-play item; position lands and playback continues.
|
|
||||||
- [ ] **Scrub on a transcoded item.** Separate case on purpose: it takes a
|
|
||||||
different path, and it silently did nothing for months (DR-238).
|
|
||||||
- [ ] **Pause and resume** — the button follows the player. It stopped doing so
|
|
||||||
when a property was handled but never observed (DR-239).
|
|
||||||
- [ ] **Fullscreen** — the window really fills the display. Measure it if
|
|
||||||
unsure: the log prints `rendering WxH`, and a height short of the panel
|
|
||||||
means the document went fullscreen and the window did not (DR-240).
|
|
||||||
- [ ] **Exit the player** — audio stops. Listen; do not assume.
|
|
||||||
- [ ] **Audio-only playback** still works: mini player, queue, next/previous.
|
|
||||||
- [ ] Nothing in the log matches `PANIC` or `ERROR`.
|
|
||||||
|
|
||||||
## 4. Android
|
|
||||||
|
|
||||||
The tablet needs the *side-by-side* build. **Do not uninstall the release app**
|
|
||||||
to make an install succeed — see "Known open" for why the normal command is
|
|
||||||
currently wrong.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run android:build --device
|
|
||||||
./scripts/sync-android-sources.sh
|
|
||||||
cd src-tauri/gen/android && ANDROID_HOME="$HOME/Android/Sdk" ./gradlew \
|
|
||||||
:app:assembleUniversalDebug -x :app:rustBuildUniversalDebug \
|
|
||||||
-x :app:rustBuildArm64Debug -x :app:rustBuildArmDebug \
|
|
||||||
-x :app:rustBuildX86Debug -x :app:rustBuildX86_64Debug
|
|
||||||
adb install -r app/build/outputs/apk/universal/debug/app-universal-debug.apk
|
|
||||||
```
|
|
||||||
|
|
||||||
Confirm the package is `com.dtourolle.jellytau.debug` before installing:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
aapt2 dump packagename <apk>
|
|
||||||
```
|
|
||||||
|
|
||||||
If it says `com.dtourolle.jellytau`, the suffix was lost — **stop**, re-sync and
|
|
||||||
re-assemble. Installing it would try to replace the real app.
|
|
||||||
|
|
||||||
Then, with `adb logcat` capturing:
|
|
||||||
|
|
||||||
- [ ] Play a video. Picture, sound, and controls.
|
|
||||||
- [ ] **Scrub.** The bar has a scale — a duration of `0.0` means the seek bar has
|
|
||||||
nothing to scrub against (DR-251).
|
|
||||||
- [ ] Transcoded seek lands rather than restarting the stream. ExoPlayer seeks a
|
|
||||||
transcode in place; declaring otherwise re-opened it (DR-246).
|
|
||||||
- [ ] PiP.
|
|
||||||
- [ ] Lockscreen: controls respond and position tracks.
|
|
||||||
- [ ] **The handoff sequence, in this exact order:**
|
|
||||||
1. play a video
|
|
||||||
2. enable background audio
|
|
||||||
3. background the app — audio continues
|
|
||||||
4. foreground the app — **video returns**
|
|
||||||
5. exit the player — **everything stops**
|
|
||||||
|
|
||||||
Steps 4 and 5 are where two separate defects lived (DR-250, DR-252). Doing
|
|
||||||
the same actions in another order finds neither.
|
|
||||||
- [ ] `grep -c 'PANIC at' <logcat>` returns 0.
|
|
||||||
|
|
||||||
## 5. Regression checks with a named cause
|
|
||||||
|
|
||||||
Each of these presented as something other than its cause, which is why they are
|
|
||||||
listed separately from the feature passes above.
|
|
||||||
|
|
||||||
| Symptom to look for | Was actually | Ref |
|
|
||||||
|---|---|---|
|
|
||||||
| Skip on a transcoded item does nothing, or jumps to zero | Seek strategy keyed on the container, not the engine | DR-238, DR-246 |
|
|
||||||
| Play/pause button does not follow the player | A property handled but never observed, so the event never arrived | DR-239 |
|
|
||||||
| Fullscreen leaves a strip of desktop | The document went fullscreen, the window did not | DR-240 |
|
|
||||||
| Resume plays from the beginning | A seek issued before the engine had a file was discarded | DR-241 |
|
|
||||||
| Scrub bar has no scale | Duration reported as `0.0` and believed | DR-251 |
|
|
||||||
| Black screen, no controls, after a background-audio round trip | A junk duration converted to a `Duration` panicked the backend | DR-252 |
|
|
||||||
| Audio still playing after leaving the player | The stop was aimed at whichever renderer bookkeeping believed was active | DR-250 |
|
|
||||||
|
|
||||||
## Known open — decide, do not discover
|
|
||||||
|
|
||||||
None of these are fixed. Each needs an explicit ship / do-not-ship call rather
|
|
||||||
than being met with surprise during testing.
|
|
||||||
|
|
||||||
- **Resume is device-local.** Progress is read from the local database and
|
|
||||||
nothing consults the server's `UserData`. A fresh install, a second device or
|
|
||||||
a reinstall offers no resume even though the server knows the position. Not a
|
|
||||||
regression — it has always been so.
|
|
||||||
- **The background-audio handoff is an unconfirmed state swap.**
|
|
||||||
`exit_background_audio` marks the video element the player again the moment it
|
|
||||||
is called, while the element has not reloaded. DR-250 makes the visible
|
|
||||||
symptom impossible; the race is intact and can still misdirect a lockscreen
|
|
||||||
command or a position read. See
|
|
||||||
[media-player-controller.md](specs/media-player-controller.md).
|
|
||||||
- 🔴 **The side-by-side debug install is broken.** `bun run android:dev`
|
|
||||||
produces an APK with the *release* application id, because the Tauri build
|
|
||||||
regenerates `gen/build.gradle.kts` after the sync drops the `.debug` suffix in.
|
|
||||||
It then fails on signatures, and its own error message advises uninstalling —
|
|
||||||
which would destroy the real app's data. **Fix this before anyone else builds
|
|
||||||
for Android.**
|
|
||||||
- **`PlayerBackend` still exists** behind `LegacyPlayer`, and the frontend still
|
|
||||||
carries some playback state. DR-248 and DR-249 are not started.
|
|
||||||
|
|
||||||
## Ship criteria
|
|
||||||
|
|
||||||
Ship when:
|
|
||||||
|
|
||||||
1. Every automated gate in §1 passes.
|
|
||||||
2. Conformance matches §2 exactly, deviations included.
|
|
||||||
3. §3 and §4 are complete, on real hardware, by a person.
|
|
||||||
4. §5 shows no symptom returning.
|
|
||||||
5. Every item in "Known open" has a recorded decision.
|
|
||||||
|
|
||||||
Do not ship on green suites alone. Both regressions introduced during this work
|
|
||||||
passed every suite and were caught by a person using the app.
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
# Release Checklist
|
|
||||||
|
|
||||||
Quick reference for creating a JellyTau release.
|
|
||||||
|
|
||||||
## Pre-Release (1-2 days before)
|
|
||||||
|
|
||||||
- [ ] Code is on `master`/`main` branch
|
|
||||||
- [ ] All feature branches are merged and tested
|
|
||||||
- [ ] No failing tests locally: `bun run test` and `bun run test:rust`
|
|
||||||
- [ ] Requirement traceability check passes: `bun run traces:json`
|
|
||||||
- [ ] Type checking passes: `bun run check`
|
|
||||||
|
|
||||||
## Update Version (Day before)
|
|
||||||
|
|
||||||
- [ ] Decide on version number (semantic versioning)
|
|
||||||
- Example: `v1.2.0` (major.minor.patch)
|
|
||||||
- Example: `v1.0.0-rc1` (release candidate)
|
|
||||||
- Example: `v1.0.0-beta` (beta)
|
|
||||||
|
|
||||||
- [ ] Update version in files:
|
|
||||||
```bash
|
|
||||||
# Check these files for version numbers
|
|
||||||
cat package.json | grep version
|
|
||||||
cat src-tauri/tauri.conf.json | grep version
|
|
||||||
cat src-tauri/Cargo.toml | grep version
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Update `CHANGELOG.md`:
|
|
||||||
- [ ] Add section for new version
|
|
||||||
- [ ] List all features added
|
|
||||||
- [ ] List all bugs fixed
|
|
||||||
- [ ] List breaking changes (if any)
|
|
||||||
- [ ] Add upgrade instructions (if needed)
|
|
||||||
- [ ] Format: Markdown with clear sections
|
|
||||||
|
|
||||||
- [ ] Update `README.md`:
|
|
||||||
- [ ] Update any version references
|
|
||||||
- [ ] Update feature list if applicable
|
|
||||||
- [ ] Update requirements if changed
|
|
||||||
|
|
||||||
- [ ] Commit changes:
|
|
||||||
```bash
|
|
||||||
git add .
|
|
||||||
git commit -m "Bump version to v1.2.0"
|
|
||||||
git push origin master
|
|
||||||
```
|
|
||||||
|
|
||||||
## Final Check Before Release
|
|
||||||
|
|
||||||
- [ ] Run full test suite:
|
|
||||||
```bash
|
|
||||||
bun run test # Frontend tests
|
|
||||||
bun run test:rust # Rust tests
|
|
||||||
bun run check # Type checking
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Build locally (optional but recommended):
|
|
||||||
```bash
|
|
||||||
# Test Linux build
|
|
||||||
bun run tauri build
|
|
||||||
|
|
||||||
# Test Android build
|
|
||||||
bun run tauri android build
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] No uncommitted changes:
|
|
||||||
```bash
|
|
||||||
git status # Should show clean working directory
|
|
||||||
```
|
|
||||||
|
|
||||||
## Release (Tag & Push)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Create annotated tag with release notes
|
|
||||||
git tag -a v1.2.0 -m "Release version 1.2.0
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- New feature 1
|
|
||||||
- New feature 2
|
|
||||||
|
|
||||||
Fixes:
|
|
||||||
- Fixed bug 1
|
|
||||||
- Fixed bug 2
|
|
||||||
|
|
||||||
Improvements:
|
|
||||||
- Performance improvement 1
|
|
||||||
- UI improvement 1
|
|
||||||
|
|
||||||
Breaking Changes:
|
|
||||||
- None (or list if applicable)
|
|
||||||
|
|
||||||
Migration:
|
|
||||||
- No action required (or include steps if applicable)"
|
|
||||||
|
|
||||||
# 2. Push tag to trigger workflow
|
|
||||||
git push origin v1.2.0
|
|
||||||
|
|
||||||
# 3. Monitor in Gitea Actions
|
|
||||||
# Go to Actions tab and watch the workflow run
|
|
||||||
```
|
|
||||||
|
|
||||||
## During Release (While Workflow Runs)
|
|
||||||
|
|
||||||
- [ ] Watch workflow progress in Gitea Actions
|
|
||||||
- [ ] Monitor for test failures
|
|
||||||
- [ ] Monitor for build failures
|
|
||||||
- [ ] Check build logs if any step fails
|
|
||||||
|
|
||||||
## After Release (Workflow Complete)
|
|
||||||
|
|
||||||
- [ ] Download artifacts from release page:
|
|
||||||
- [ ] `JellyTau_*.AppImage` (Linux)
|
|
||||||
- [ ] `JellyTau_*.deb` (Linux)
|
|
||||||
- [ ] `JellyTau-*.rpm` (Linux)
|
|
||||||
- [ ] `jellytau-release.apk` (Android)
|
|
||||||
- [ ] `jellytau-release.aab` (Android)
|
|
||||||
|
|
||||||
- [ ] Basic testing of artifacts:
|
|
||||||
- [ ] Linux AppImage runs
|
|
||||||
- [ ] Linux DEB installs and runs
|
|
||||||
- [ ] Android APK installs (via `adb` or sideload)
|
|
||||||
|
|
||||||
- [ ] Verify release page:
|
|
||||||
- [ ] Title is correct: "JellyTau vX.Y.Z"
|
|
||||||
- [ ] Release notes are formatted correctly
|
|
||||||
- [ ] All artifacts are uploaded
|
|
||||||
- [ ] Release type is correct (prerelease vs release)
|
|
||||||
|
|
||||||
- [ ] Verify integrity metadata (DR-216):
|
|
||||||
- [ ] `SHA256SUMS` is present, and `sha256sum -c SHA256SUMS` passes in the
|
|
||||||
directory you downloaded into
|
|
||||||
- [ ] SBOM files are present (`*.cdx.json`, `frontend-dependencies.txt`)
|
|
||||||
|
|
||||||
- [ ] Verify the update path (DR-217) — this is the step that catches a broken
|
|
||||||
updater *before* users hit it, because a bad manifest fails only on their
|
|
||||||
machine:
|
|
||||||
- [ ] `latest.json` is live and names this version:
|
|
||||||
`curl -s https://gitea.tourolle.paris/dtourolle/jellytau/raw/branch/updater/latest.json | jq .version`
|
|
||||||
- [ ] Both platform entries carry a non-empty `signature`
|
|
||||||
- [ ] The `.AppImage.tar.gz`, its `.sig`, and the NSIS `.sig` are among the
|
|
||||||
release assets — the manifest points at them
|
|
||||||
- [ ] Install the **previous** release, launch it, and use Settings → Updates:
|
|
||||||
it should offer this version, install it, and relaunch
|
|
||||||
- [ ] On Android, Settings → Updates offers the releases page rather than an
|
|
||||||
install button (the updater plugin is not compiled for that target)
|
|
||||||
|
|
||||||
- [ ] Announce release:
|
|
||||||
- [ ] Post to relevant channels/communities
|
|
||||||
- [ ] Update website/docs
|
|
||||||
- [ ] Tag contributors if applicable
|
|
||||||
|
|
||||||
## Rollback (If Issues Found)
|
|
||||||
|
|
||||||
If critical issues are found after release:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Option 1: Delete tag locally and remotely
|
|
||||||
git tag -d v1.2.0
|
|
||||||
git push origin :refs/tags/v1.2.0
|
|
||||||
|
|
||||||
# Option 2: Mark as prerelease in release page
|
|
||||||
# Then plan immediate patch release (v1.2.1)
|
|
||||||
|
|
||||||
# Option 3: Create hotfix branch and release v1.2.1
|
|
||||||
git checkout -b hotfix/v1.2.1
|
|
||||||
# Fix issues
|
|
||||||
git commit -m "Fix critical issue"
|
|
||||||
git tag v1.2.1
|
|
||||||
git push origin hotfix/v1.2.1 v1.2.1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Version Examples
|
|
||||||
|
|
||||||
### Major Release
|
|
||||||
```
|
|
||||||
v2.0.0 - Major version bump
|
|
||||||
- Significant new features
|
|
||||||
- Breaking API changes
|
|
||||||
- Major UI redesign
|
|
||||||
```
|
|
||||||
|
|
||||||
### Minor Release
|
|
||||||
```
|
|
||||||
v1.2.0 - Feature release
|
|
||||||
- New features
|
|
||||||
- Backward compatible
|
|
||||||
- Bug fixes
|
|
||||||
```
|
|
||||||
|
|
||||||
### Patch Release
|
|
||||||
```
|
|
||||||
v1.1.1 - Bug fix/patch
|
|
||||||
- Bug fixes only
|
|
||||||
- No new features
|
|
||||||
- Backward compatible
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pre-releases
|
|
||||||
```
|
|
||||||
v1.2.0-alpha - Early development
|
|
||||||
v1.2.0-beta - Late development, feature complete
|
|
||||||
v1.2.0-rc1 - Release candidate, minimal fixes only
|
|
||||||
```
|
|
||||||
|
|
||||||
## File Locations
|
|
||||||
|
|
||||||
Key files for versioning:
|
|
||||||
- `package.json` - Frontend version
|
|
||||||
- `src-tauri/tauri.conf.json` - Tauri config version
|
|
||||||
- `src-tauri/Cargo.toml` - Rust version
|
|
||||||
- `CHANGELOG.md` - Release history
|
|
||||||
- `README.md` - Project documentation
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Tests Fail Before Release
|
|
||||||
1. Don't push tag yet
|
|
||||||
2. Fix failing tests locally
|
|
||||||
3. Push fixes to master
|
|
||||||
4. Re-run test suite
|
|
||||||
5. Then tag and push
|
|
||||||
|
|
||||||
### Build Fails in CI
|
|
||||||
1. Check detailed logs in Gitea Actions
|
|
||||||
2. Fix issue locally
|
|
||||||
3. Delete tag: `git tag -d v1.2.0 && git push origin :refs/tags/v1.2.0`
|
|
||||||
4. Push fix to master
|
|
||||||
5. Create new tag with fix
|
|
||||||
|
|
||||||
### Release Already Exists
|
|
||||||
1. If workflow runs twice, artifacts may conflict
|
|
||||||
2. Check release page
|
|
||||||
3. If duplicates exist, delete and re-release
|
|
||||||
|
|
||||||
### Artifacts Missing
|
|
||||||
1. Check build logs for errors
|
|
||||||
2. Verify platform-specific dependencies
|
|
||||||
3. Delete tag and retry after fixes
|
|
||||||
|
|
||||||
## Performance Tips
|
|
||||||
|
|
||||||
- Tests: ~5-10 minutes
|
|
||||||
- Linux build: ~10-15 minutes
|
|
||||||
- Android build: ~15-20 minutes
|
|
||||||
- Total release time: ~30-45 minutes
|
|
||||||
|
|
||||||
First build takes longer (cache warming). Subsequent releases are faster due to caching.
|
|
||||||
|
|
||||||
## Template: Release Notes
|
|
||||||
|
|
||||||
```
|
|
||||||
## 🎉 JellyTau vX.Y.Z
|
|
||||||
|
|
||||||
### ✨ Features
|
|
||||||
- New feature 1
|
|
||||||
- New feature 2
|
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
|
||||||
- Fixed issue #123
|
|
||||||
- Fixed issue #456
|
|
||||||
|
|
||||||
### 🚀 Performance
|
|
||||||
- Improvement 1
|
|
||||||
- Improvement 2
|
|
||||||
|
|
||||||
### 📱 Downloads
|
|
||||||
- [Linux AppImage](#) - Run on any Linux
|
|
||||||
- [Linux DEB](#) - Install on Ubuntu/Debian
|
|
||||||
- [Android APK](#) - Install on Android devices
|
|
||||||
- [Android AAB](#) - For Google Play Store
|
|
||||||
|
|
||||||
### 📋 Requirements
|
|
||||||
**Linux:** 64-bit, GLIBC 2.29+
|
|
||||||
**Android:** 8.0+
|
|
||||||
|
|
||||||
### 🔗 Links
|
|
||||||
- [Changelog](https://gitea.tourolle.paris/dtourolle/jellytau/src/branch/master/CHANGELOG.md)
|
|
||||||
- [Issues](https://gitea.tourolle.paris/dtourolle/jellytau/issues)
|
|
||||||
|
|
||||||
---
|
|
||||||
Built with Tauri, SvelteKit, and Rust 🦀
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# View existing tags
|
|
||||||
git tag -l
|
|
||||||
|
|
||||||
# Create release locally (dry run)
|
|
||||||
git tag -a v1.2.0 -m "Release v1.2.0" --dry-run
|
|
||||||
|
|
||||||
# List commits since last tag
|
|
||||||
git log v1.1.0..HEAD --oneline
|
|
||||||
|
|
||||||
# Show tag details
|
|
||||||
git show v1.2.0
|
|
||||||
|
|
||||||
# Rename tag (if needed)
|
|
||||||
git tag v1.2.0_old v1.2.0
|
|
||||||
git tag -d v1.2.0
|
|
||||||
git push origin v1.2.0_old v1.2.0
|
|
||||||
|
|
||||||
# Delete tag locally and remotely
|
|
||||||
git tag -d v1.2.0
|
|
||||||
git push origin :refs/tags/v1.2.0
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Tips:**
|
|
||||||
- ✅ Always test locally before release
|
|
||||||
- ✅ Use semantic versioning consistently
|
|
||||||
- ✅ Document changes in CHANGELOG
|
|
||||||
- ✅ Wait for full workflow completion
|
|
||||||
- ✅ Test release artifacts before announcing
|
|
||||||
|
|
||||||
**Remember:** A good release is a tested release! 🚀
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,89 +0,0 @@
|
|||||||
# Specs index
|
|
||||||
|
|
||||||
Feature specs for JellyTau. Start a new one from
|
|
||||||
[SPEC-TEMPLATE.md](SPEC-TEMPLATE.md) and run it past
|
|
||||||
[SPEC-REVIEW-CHECKLIST.md](SPEC-REVIEW-CHECKLIST.md) before accepting it.
|
|
||||||
|
|
||||||
## What lives here
|
|
||||||
|
|
||||||
**Only work that has not shipped.** Once a spec is fully implemented its design
|
|
||||||
is folded into the architecture docs — which are the maintained description of
|
|
||||||
the build — and the spec file is deleted. Git history keeps the original,
|
|
||||||
including its rejected alternatives and acceptance criteria; the architecture
|
|
||||||
docs keep the reasoning that a future change still needs.
|
|
||||||
|
|
||||||
So: a file in this directory is a **promise, not a description**. If you want to
|
|
||||||
know how something *works*, read
|
|
||||||
[docs/architecture/](../architecture/README.md). If you want to know what is
|
|
||||||
*planned*, read here.
|
|
||||||
|
|
||||||
**Status vocabulary**
|
|
||||||
|
|
||||||
| Status | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| Proposed | Written, not accepted. Nothing built. |
|
|
||||||
| Accepted | Agreed as the design; implementation not started or not finished. |
|
|
||||||
| Partially implemented | Some parts shipped; the spec names what is left. |
|
|
||||||
| Design authority | No code of its own — it records a decision later specs act on. |
|
|
||||||
|
|
||||||
**Next free requirement ids** (always re-check
|
|
||||||
[requirements.md](../requirements.md) before allocating): **UR-086**,
|
|
||||||
**IR-036**, **JA-038**, **DR-295**, **UT-264** (DR-289/290 went to the v0.12.2
|
|
||||||
download fixes; DR-291/292 and UT-255/257/258 to the offline-banner and
|
|
||||||
server-only-reveal work; DR-293/294 and UT-259-263 to Android's FFmpeg decoding
|
|
||||||
and offline-without-network). Three specs below suggested ids that have
|
|
||||||
since been taken by other work; each carries a ⚠️ note at the top — this line
|
|
||||||
was itself stale by five, two and forty-seven until 2026-09-08, which is why the
|
|
||||||
re-check is not optional.
|
|
||||||
|
|
||||||
## Partially implemented
|
|
||||||
|
|
||||||
| Spec | What landed | What is left |
|
|
||||||
|---|---|---|
|
|
||||||
| [frontend-domain-model.md](frontend-domain-model.md) | Catalog surface: `MediaKind`, `from_jellyfin` isolated, ticks → ms | `primaryImageTag` → `imageId` (~30 sites); player/session/reporting tick math; `stream.type` |
|
|
||||||
| [jellyfin-server-version-compatibility.md](jellyfin-server-version-compatibility.md) | Route table, `ServerCapabilities`, the auth-spelling fix (the one thing 12.0 actually breaks), explicit `Recursive`, cache generation stamping, the frontend route leak, the unsupported-server state, and an HTTP-level harness that runs the repository against both generations | DR-283: two resolved flags are not consumed yet, and `honours_directplay_audio_codec` is unestablished for 12.x — both need a running 12.x server. Nothing has been tested against a real server of either generation |
|
|
||||||
| [libmpv2-migration.md](libmpv2-migration.md) | `LICENSE` | The `libmpv` → `libmpv2` crate swap |
|
|
||||||
| [read-through-media-cache.md](read-through-media-cache.md) | DR-126…128, DR-133…138 — cache entries *are* download rows; local playback of downloads | DR-122/124/125 — the read-through capture. DR-121 shipped as backend-owned stream selection and left this spec |
|
|
||||||
| [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md) | Stage 1: `SearchScope` owned by Rust (DR-063…067) | Stage 2: result-side grouping (`GROUP_ITEM_TYPES` still in `searchScope.ts`) |
|
|
||||||
|
|
||||||
## Not started
|
|
||||||
|
|
||||||
| Spec | Blocked on / note |
|
|
||||||
|---|---|
|
|
||||||
| [desktop-native-video.md](desktop-native-video.md) | mpv draws video on every desktop platform, then the webview `<video>` path and hls.js are deleted. Converts a measured 7% direct-play rate toward Android's 85%. Stacked on backend-owned stream selection. |
|
|
||||||
| [backend-owned-stream-selection.md](backend-owned-stream-selection.md) | Rust owns direct-play-vs-transcode, transport and quality; players consume one `StreamSelection`. Partly built — `StreamSelection`, `Transport` and the `.m3u8` sniff removal have landed. |
|
|
||||||
| [build-provenance.md](build-provenance.md) | `build.rs` is still bare. ⚠️ suggested id DR-093 is taken. |
|
|
||||||
| [player-facade-enforcement.md](player-facade-enforcement.md) | ~60 `commands.player*` sites still outside the facade; no lint rule. ⚠️ suggested id DR-095 is taken. |
|
|
||||||
| [windows-native-audio-backend.md](windows-native-audio-backend.md) | Blocked on the libmpv2 swap. ⚠️ suggested id IR-030 is taken. |
|
|
||||||
| [linux-native-video-spike.md](linux-native-video-spike.md) | **Spike run 2026-08-21: compositing works on Linux, X11 and Wayland.** G1-G6 green bar the Tauri `default_vbox()` half of G1. The adaptive-bitrate question it was waiting on is **answered**: the server publishes one `EXT-X-STREAM-INF`, so there is no ladder for mpv to lose (DR-229). `StreamSelection` (DR-225) is the contract to consume. |
|
|
||||||
|
|
||||||
## Design authority
|
|
||||||
|
|
||||||
| Spec | Role |
|
|
||||||
|---|---|
|
|
||||||
| [playback-backend-unification.md](playback-backend-unification.md) | Why video cannot unify onto one native engine and audio can. The audio half has since shipped on Android; Windows has not. |
|
|
||||||
| [scoped-search-boundary.md](scoped-search-boundary.md) | The boundary design the `check:boundary` rule came from. Stage 1 built. |
|
|
||||||
| [scoped-search.md](scoped-search.md) | Superseded in part — its "frontend only, no Rust changes" decision is the leak the boundary spec reversed. UX still current. |
|
|
||||||
|
|
||||||
## Where the shipped specs went
|
|
||||||
|
|
||||||
Sixteen specs were folded into the architecture docs and deleted (2026-08-21).
|
|
||||||
Where to look for each:
|
|
||||||
|
|
||||||
| Shipped work | Now documented in |
|
|
||||||
|---|---|
|
|
||||||
| Account menu & global chrome | [02-svelte-frontend.md](../architecture/02-svelte-frontend.md) — App Shell and Chrome |
|
|
||||||
| Library mosaic | [02-svelte-frontend.md](../architecture/02-svelte-frontend.md) — Library Mosaic |
|
|
||||||
| Series current-episode navigation | [02-svelte-frontend.md](../architecture/02-svelte-frontend.md) — Series and Episode Navigation |
|
|
||||||
| Downloads as an offline library | [02-svelte-frontend.md](../architecture/02-svelte-frontend.md) — Downloaded Browse |
|
|
||||||
| Favourites browsing | [01-rust-backend.md](../architecture/01-rust-backend.md) — Favorites System |
|
|
||||||
| Streaming bitrate cap | [01-rust-backend.md](../architecture/01-rust-backend.md) — Streaming quality ladder |
|
|
||||||
| Locally-indexed search | [03-data-flow.md](../architecture/03-data-flow.md) — Search Flow; [01-rust-backend.md](../architecture/01-rust-backend.md) — Background workers |
|
|
||||||
| Offline downloaded-only filter | [06-downloads-and-offline.md](../architecture/06-downloads-and-offline.md) — Offline Catalog Visibility |
|
|
||||||
| Audio equalizer · Android audio settings parity | [05-platform-backends.md](../architecture/05-platform-backends.md) — Audio settings on ExoPlayer |
|
|
||||||
| Android native video spike | [05-platform-backends.md](../architecture/05-platform-backends.md) — Native Video Compositing |
|
|
||||||
| Video background audio | [05-platform-backends.md](../architecture/05-platform-backends.md) — Background Audio Handoff |
|
|
||||||
| Traceability gate repair | [traceability-ci.md](../traceability-ci.md) |
|
|
||||||
| Boundary tripwire hardening | `scripts/check-frontend-boundary.sh` (its header is the spec) |
|
|
||||||
| Original-file downloads & Android FFmpeg decoding (was on-device-audio-remux) | [05-platform-backends.md](../architecture/05-platform-backends.md) — Licensed audio codecs; [06-downloads-and-offline.md](../architecture/06-downloads-and-offline.md) — What a Video Download Fetches, Offline Means No Network |
|
|
||||||
| Playback docs corrections · req-coverage script removal | Nothing to document — both were corrections that have been applied |
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
# Spec review checklist
|
|
||||||
|
|
||||||
Run a spec past this before accepting it. It exists because JellyTau's
|
|
||||||
backend/frontend boundary is a **stated rule with, historically, no gate** — the
|
|
||||||
rule lived in the architecture docs, but nothing forced a spec author to check a
|
|
||||||
new design against it, and a "minimal-change" spec quietly leaked domain
|
|
||||||
taxonomy into the frontend (see [scoped-search-boundary.md](scoped-search-boundary.md)).
|
|
||||||
This checklist is the human gate. The CI check
|
|
||||||
(`scripts/check-frontend-boundary.sh`) is only a crude tripwire for one leak
|
|
||||||
signature — it does **not** replace this.
|
|
||||||
|
|
||||||
Copy the boxes into the review comment (or the PR) and tick them.
|
|
||||||
|
|
||||||
## Boundary (the one that bites)
|
|
||||||
|
|
||||||
- [ ] **The spec has a filled-in "Layer assignment" table**, and it assigns
|
|
||||||
*logic*, not files. A spec without this section is not ready to review.
|
|
||||||
- [ ] **No domain vocabulary is placed in the frontend.** In particular: Jellyfin
|
|
||||||
item-type sets that define a *category* (what "Music"/"TV"/"Movies" means),
|
|
||||||
query-shaping rules, business rules, reachability/sync policy. If the
|
|
||||||
frontend names a *set* of item types to define a category, that is a leak —
|
|
||||||
it belongs behind an opaque enum the backend expands.
|
|
||||||
- [ ] **"The backend already accepts this parameter" was not used as the reason**
|
|
||||||
to place the deciding logic in the frontend. Accepting a parameter ≠ owning
|
|
||||||
the decision of its value.
|
|
||||||
- [ ] **The `Scope:` / effort framing is not optimizing for "least backend
|
|
||||||
change."** "Frontend only, no Rust changes" is a description, never a goal.
|
|
||||||
The goal is *correct layer placement*; sometimes that is more Rust work.
|
|
||||||
- [ ] Ran the litmus test on each borderline responsibility: *would it change if
|
|
||||||
Jellyfin's API changed?* → Rust. *Only if the UI were redesigned?* →
|
|
||||||
frontend. Borderline defaults to Rust.
|
|
||||||
- [ ] Single-type presentation (`itemType: "Movie"`, "this page shows albums")
|
|
||||||
is **not** over-corrected into the backend. The rule targets category
|
|
||||||
*taxonomy*, not every mention of a type. Don't invent a backend enum per
|
|
||||||
list page.
|
|
||||||
|
|
||||||
## IPC contract
|
|
||||||
|
|
||||||
- [ ] Anything crossing the boundary has its wire shape specified.
|
|
||||||
- [ ] camelCase rule accounted for: top-level params auto-convert; nested structs
|
|
||||||
get `#[serde(rename_all = "camelCase")]`; tagged unions match tags on both
|
|
||||||
sides; events are kebab-case. (CLAUDE.md §IPC,
|
|
||||||
[04-type-sync-and-threading.md](../architecture/04-type-sync-and-threading.md).)
|
|
||||||
- [ ] Any result that arrives *twice* (command return **and** a later event —
|
|
||||||
e.g. the search cache/server merge) has **both** payloads in the new shape.
|
|
||||||
- [ ] `bindings.ts` is regenerated from Rust, not hand-edited.
|
|
||||||
|
|
||||||
## Requirements & traceability
|
|
||||||
|
|
||||||
- [ ] Linked to existing URs, or new URs/DRs are allocated in
|
|
||||||
[requirements.md](../requirements.md).
|
|
||||||
- [ ] Requirement-implementing code will carry `// TRACES:` comments (CLAUDE.md).
|
|
||||||
- [ ] Traceability coverage stays ≥ 88% (the CI gate — a ratchet, so check
|
|
||||||
`bun run traces:coverage` rather than trusting this number).
|
|
||||||
|
|
||||||
## Lifecycle
|
|
||||||
|
|
||||||
- [ ] **"Destination on completion" names a real architecture doc and section.**
|
|
||||||
This spec file is deleted when it ships; something has to absorb the
|
|
||||||
design. If nothing fits, the layer assignment is probably unclear — go back
|
|
||||||
to that table.
|
|
||||||
- [ ] The spec separates the **durable half** (invariants, rejected alternatives,
|
|
||||||
the defect a decision exists to prevent) from the **disposable half**
|
|
||||||
(phases, migration steps, acceptance criteria). Only the first is folded in.
|
|
||||||
- [ ] Anything listed as out of scope but still worth doing is written where it
|
|
||||||
will be found after this file is gone — beside the code it concerns.
|
|
||||||
|
|
||||||
## Conflicts & hygiene
|
|
||||||
|
|
||||||
- [ ] If this spec revises/supersedes another, the older spec gets a banner
|
|
||||||
pointing here — no two specs silently contradicting.
|
|
||||||
- [ ] Acceptance criteria include the standard gates: `bun run check`,
|
|
||||||
`bun run test`, `bun run check:boundary`, and (if Rust changed)
|
|
||||||
`cargo fmt`/`cargo clippy`/`bun run test:rust`.
|
|
||||||
- [ ] Notes flag that a parallel Claude session may be active in the repo.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**If any Boundary box can't be ticked, the spec is not ready** — fix the layer
|
|
||||||
assignment first. Every other section can be negotiated; that one is the whole
|
|
||||||
reason this file exists.
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
# Spec: <feature name>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Copy this file to docs/specs/<kebab-name>.md and fill it in. Delete the HTML
|
|
||||||
comments as you go. The section that matters most for this project is
|
|
||||||
"Layer assignment" — read its comment before writing it.
|
|
||||||
|
|
||||||
Before merging a spec, run it past docs/specs/SPEC-REVIEW-CHECKLIST.md.
|
|
||||||
|
|
||||||
LIFECYCLE: this file is temporary. docs/specs/ holds only unshipped work — when
|
|
||||||
the last acceptance criterion is met, the design is folded into
|
|
||||||
docs/architecture/ and this file is deleted in the same commit. Write it
|
|
||||||
knowing that: the durable half is the reasoning (invariants, rejected
|
|
||||||
alternatives, the defect a decision prevents), and the disposable half is the
|
|
||||||
plan (phases, migration steps, acceptance criteria).
|
|
||||||
-->
|
|
||||||
|
|
||||||
**Status:** Proposed <!-- Proposed | Accepted | Partially implemented | Superseded.
|
|
||||||
NOT "Implemented" — a fully shipped spec is folded into docs/architecture/
|
|
||||||
and deleted. See "Destination on completion" below. -->
|
|
||||||
**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. -->
|
|
||||||
**Destination on completion:** <!--
|
|
||||||
Which architecture doc absorbs this design when it ships, and roughly which
|
|
||||||
section. e.g. "05-platform-backends.md — a new section beside
|
|
||||||
ExoPlayerBackend". Name it NOW: a feature that fits no existing doc usually
|
|
||||||
has an unclear layer assignment, which is worth finding out at spec time.
|
|
||||||
This spec file is deleted in the same commit that folds it in. -->
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
<!-- 2–4 sentences. What changes for the user, in plain terms. -->
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
<!-- Why now. The problem being solved. -->
|
|
||||||
|
|
||||||
## Layer assignment
|
|
||||||
|
|
||||||
<!--
|
|
||||||
🔴 THIS IS THE SECTION THAT KEEPS THE ARCHITECTURE HONEST. Do not skip it, and
|
|
||||||
do NOT reframe it as "how little backend work can we get away with."
|
|
||||||
|
|
||||||
The project rule (CLAUDE.md, architecture/02-svelte-frontend.md): the Rust
|
|
||||||
backend owns ALL business logic — auth, catalog, sessions, downloads, offline,
|
|
||||||
playback, AND domain vocabulary (e.g. what Jellyfin item types the category
|
|
||||||
"Music" means). The Svelte frontend is PRESENTATION ONLY: rendering, layout,
|
|
||||||
navigation, view/order preferences, input handling.
|
|
||||||
|
|
||||||
For each distinct piece of *logic* this feature introduces, put it in the table
|
|
||||||
and name the layer it belongs to and WHY. "It's less work in the frontend" and
|
|
||||||
"the backend already accepts this parameter" are NOT reasons to place logic in
|
|
||||||
the frontend — the backend accepting a parameter does not make deciding that
|
|
||||||
parameter's value a presentation concern.
|
|
||||||
|
|
||||||
Litmus test for "does this belong in Rust?": Would this logic have to change if
|
|
||||||
Jellyfin changed its API, added an item type, or altered a business rule? If
|
|
||||||
yes, it is domain logic → Rust. Would it change if we redesigned the UI? If
|
|
||||||
yes (and only yes), it is presentation → frontend.
|
|
||||||
|
|
||||||
A past incident: scoped-search.md placed the item-type taxonomy (what "Music"
|
|
||||||
means as a set of Jellyfin types) in the frontend because the backend already
|
|
||||||
accepted an includeItemTypes filter. That was a boundary leak; see
|
|
||||||
scoped-search-boundary.md. This section exists to catch that class of mistake
|
|
||||||
at spec time, not in review three features later.
|
|
||||||
-->
|
|
||||||
|
|
||||||
| Logic / responsibility | Layer | Why it belongs there |
|
|
||||||
|------------------------|-------|----------------------|
|
|
||||||
| <!-- e.g. scope → item-types --> | Rust | <!-- domain vocabulary; changes with Jellyfin's API --> |
|
|
||||||
| <!-- e.g. group display order --> | Frontend | <!-- pure presentation; changes only if UI is redesigned --> |
|
|
||||||
|
|
||||||
<!--
|
|
||||||
If a row is genuinely borderline, say so and give the tie-breaker you used.
|
|
||||||
Borderline defaults to Rust for anything touching domain data or vocabulary.
|
|
||||||
-->
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
<!--
|
|
||||||
How it works. Wire shapes for anything crossing the IPC boundary. Remember:
|
|
||||||
- Command NAME must match the Rust fn name exactly.
|
|
||||||
- Top-level params auto-convert snake_case → camelCase (Tauri v2).
|
|
||||||
- Nested struct fields need #[serde(rename_all = "camelCase")].
|
|
||||||
- Events are kebab-case.
|
|
||||||
(See CLAUDE.md §IPC and architecture/04-type-sync-and-threading.md.)
|
|
||||||
|
|
||||||
Regenerate bindings.ts from Rust types; never hand-edit it.
|
|
||||||
-->
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
<!-- What this spec deliberately does NOT do. -->
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
<!-- Checkable statements. Include the standard gates: -->
|
|
||||||
|
|
||||||
- [ ] `bun run check` and `bun run test` pass.
|
|
||||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes (if Rust changed).
|
|
||||||
- [ ] `bun run check:boundary` passes (no taxonomy leak into the frontend).
|
|
||||||
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
|
||||||
- [ ] `bindings.ts` regenerated if Rust types changed.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
<!-- Rust: cargo test. Frontend: vitest, src/lib/**/*.test.ts. What to cover. -->
|
|
||||||
|
|
||||||
## TRACES
|
|
||||||
|
|
||||||
<!-- Suggested tags per new/changed piece: UR-xxx | DR-yyy | tests. -->
|
|
||||||
|
|
||||||
## Notes for the implementer
|
|
||||||
|
|
||||||
<!--
|
|
||||||
- A parallel Claude session may be active in this repo — `git diff` before
|
|
||||||
"repairing" unexpected changes (see project memory / CLAUDE.md gotchas).
|
|
||||||
- Anything else non-obvious.
|
|
||||||
-->
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
# Spec: Backend-owned stream selection
|
|
||||||
|
|
||||||
**Status:** Proposed
|
|
||||||
**Requirements:** UR-079 (new) → DR-219 … DR-224 (new); **implements and extends
|
|
||||||
DR-121**, currently allocated to
|
|
||||||
[read-through-media-cache.md](read-through-media-cache.md) and not started.
|
|
||||||
Re-check `requirements.md` before allocating — the ids moved twice while this was
|
|
||||||
being written (`DR` max was 215, then 218).
|
|
||||||
**UX spec:** the quality selector in `VideoPlayer.svelte` already exists; this
|
|
||||||
changes what fills it, not how it looks.
|
|
||||||
**Supersedes / revises:** takes DR-121 out of
|
|
||||||
[read-through-media-cache.md](read-through-media-cache.md), which should keep
|
|
||||||
only its capture/eviction half. Unblocks
|
|
||||||
[linux-native-video-spike.md](linux-native-video-spike.md).
|
|
||||||
|
|
||||||
**Destination on completion:**
|
|
||||||
[01-rust-backend.md](../architecture/01-rust-backend.md) — extends the
|
|
||||||
"Streaming quality ladder" section; and
|
|
||||||
[03-data-flow.md](../architecture/03-data-flow.md) — playback initiation. The
|
|
||||||
durable half is the layer line and the `StreamSelection` contract; phases and
|
|
||||||
acceptance criteria are disposable.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Make Rust the single owner of *which stream to play* — direct play or transcode,
|
|
||||||
at what ceiling, over what transport — and hand every player backend a
|
|
||||||
self-describing selection instead of a bare URL. mpv, ExoPlayer and the HTML5
|
|
||||||
`<video>`/hls.js path all become consumers of the same decision rather than three
|
|
||||||
places that re-derive it.
|
|
||||||
|
|
||||||
Nothing about how playback *looks* changes. What changes is that the frontend
|
|
||||||
stops inferring transport from a URL string, and that direct play becomes
|
|
||||||
possible at all.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
Four concrete problems, all the same shape.
|
|
||||||
|
|
||||||
**1. The frontend sniffs transport out of the URL.**
|
|
||||||
[VideoPlayer.svelte:569](../../src/lib/components/player/VideoPlayer.svelte#L569):
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const isHlsStream = currentStreamUrl.includes(".m3u8");
|
|
||||||
```
|
|
||||||
|
|
||||||
and again inline at line 2364. Rust *built* that URL and knows exactly what it
|
|
||||||
is; the frontend re-derives it by substring match. Change the endpoint, add a DASH
|
|
||||||
path, serve a progressive file, and this silently picks wrong. This is the
|
|
||||||
boundary rule in miniature — not item-type taxonomy, but the same error: a
|
|
||||||
domain fact reconstructed in the presentation layer because the wire shape did
|
|
||||||
not carry it.
|
|
||||||
|
|
||||||
**2. There is no direct-play path.** `get_video_stream_url` always builds an HLS
|
|
||||||
transcode URL (`TranscodingProtocol=hls`, `VideoCodec=h264` first). Every video
|
|
||||||
play burns server CPU, even when the file would play untouched. This is the cost
|
|
||||||
the Linux native-video work exists to remove, and it cannot be removed without a
|
|
||||||
decision that does not currently exist anywhere in the codebase.
|
|
||||||
|
|
||||||
**3. Quality is a process-wide global.** `streaming_quality()` /
|
|
||||||
`set_streaming_quality()` in `repository/online.rs` read and write a static.
|
|
||||||
It is not per-session or per-item, so it cannot express "this 4K remux needs a
|
|
||||||
ceiling, that podcast does not", and two concurrent playbacks would share one
|
|
||||||
setting.
|
|
||||||
|
|
||||||
**4. Rust cannot say what qualities *this* media source supports.** The selector
|
|
||||||
is populated from a fixed enum rather than from what the source actually offers.
|
|
||||||
DR-121 already names this; it has not been built.
|
|
||||||
|
|
||||||
### The prior question
|
|
||||||
|
|
||||||
Finding 3 of [playback-backend-unification.md](playback-backend-unification.md)
|
|
||||||
holds that hls.js gives us real adaptive bitrate and mpv would lose it. Evidence
|
|
||||||
in this repo suggests **there is no ABR today**: a single rendition is requested,
|
|
||||||
no level-handling code exists anywhere in the frontend, and a quality switch is
|
|
||||||
implemented by re-opening the stream.
|
|
||||||
|
|
||||||
**Run this before sizing the adaptation work.** It needs a live server:
|
|
||||||
|
|
||||||
```
|
|
||||||
curl -s "https://<server>/Videos/<itemId>/master.m3u8?api_key=<key>&…" \
|
|
||||||
| grep -c EXT-X-STREAM-INF
|
|
||||||
```
|
|
||||||
|
|
||||||
`1` → there is no adaptation to preserve, and the adaptation half of this spec
|
|
||||||
collapses to "pick well at open". `>1` → finding 3 stands and DR-223 applies.
|
|
||||||
**Everything else in this spec is worth doing either way** — the ownership
|
|
||||||
problems above are independent of the answer.
|
|
||||||
|
|
||||||
## Layer assignment
|
|
||||||
|
|
||||||
| Logic / responsibility | Layer | Why it belongs there |
|
|
||||||
|---|---|---|
|
|
||||||
| Direct play vs direct stream vs transcode | Rust | Depends on Jellyfin's `PlaybackInfo`, container/codec support and the device profile. Changes when Jellyfin's API or our profile changes → domain, by the litmus test. |
|
|
||||||
| Transport of the chosen stream (HLS / progressive / local file) | Rust | Rust constructs the URL; it is the only place that *knows* rather than infers. Today the frontend guesses from `.m3u8`. |
|
|
||||||
| Which qualities this media source can offer | Rust | Derived from the source's own streams and the quality→transcode-parameter mapping that `get_video_download_url` already holds. DR-121. |
|
|
||||||
| The quality ceiling in force, per playback session | Rust | Domain state that outlives any one view and must survive a backend swap or a mode transfer. Currently a process-wide static. |
|
|
||||||
| Deciding to re-negotiate mid-playback (if adaptation is needed) | Rust | It performs the HTTP and already derives reachability from real traffic via `ConnectivityMonitor`. Throughput estimation is the same pattern on the same data — a side-channel probe would repeat the mistake that principle exists to prevent. |
|
|
||||||
| Frame-level delivery *within* the selected stream, including a player's own ABR | **Player** | ExoPlayer has genuine adaptive selection; if Rust hands it a multi-variant playlist it should use it. Rust chooses *what to request*, never how a player paces bytes. See "The line". |
|
|
||||||
| Rendering the selector, showing the current quality, ordering the list | Frontend | Pure presentation over a backend-supplied list. |
|
|
||||||
| Poster, letterbox, controls, overlay z-order | Frontend | Unchanged. |
|
|
||||||
|
|
||||||
### The line
|
|
||||||
|
|
||||||
**Rust decides *what stream*. The player decides *how to deliver it*.**
|
|
||||||
|
|
||||||
This matters most for ExoPlayer, which already does real adaptive track selection
|
|
||||||
over HLS. This spec must not reimplement that or fight it — if a multi-variant
|
|
||||||
playlist reaches ExoPlayer, ExoPlayer adapts and Rust stays out of the way. The
|
|
||||||
same restraint applies to any future backend that gains the capability. Rust only
|
|
||||||
steps in where the player has no such ability (mpv) *and* the server actually
|
|
||||||
offers a ladder.
|
|
||||||
|
|
||||||
Borderline row, with its tie-breaker: "which media source of a multi-source item"
|
|
||||||
looks like a user choice, and its *presentation* is. The default and the
|
|
||||||
constraint set are domain → **Rust**, per the borderline-defaults-to-Rust rule.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### The contract
|
|
||||||
|
|
||||||
One self-describing selection replaces the bare URL. Nested fields are
|
|
||||||
camelCase over the wire (`#[serde(rename_all = "camelCase")]`); the enums are
|
|
||||||
tagged so the frontend matches a tag instead of parsing a string.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[derive(Serialize, Type)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct StreamSelection {
|
|
||||||
pub url: String,
|
|
||||||
pub transport: Transport,
|
|
||||||
pub playback_kind: PlaybackKind,
|
|
||||||
/// The negotiated rendition; None when direct-playing the source as-is.
|
|
||||||
pub rendition: Option<Rendition>,
|
|
||||||
/// What this media source can offer — fills the selector (DR-121).
|
|
||||||
pub available: Vec<QualityOption>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Type)]
|
|
||||||
#[serde(tag = "type", rename_all = "camelCase")]
|
|
||||||
pub enum Transport { Hls, Progressive, LocalFile }
|
|
||||||
|
|
||||||
#[derive(Serialize, Type)]
|
|
||||||
#[serde(tag = "type", rename_all = "camelCase")]
|
|
||||||
pub enum PlaybackKind { DirectPlay, DirectStream, Transcode }
|
|
||||||
```
|
|
||||||
|
|
||||||
`Transport` is the field that deletes the `.m3u8` sniff. The frontend picks
|
|
||||||
hls.js on `Hls` and the element's own loader otherwise — a tag match, not a
|
|
||||||
substring search.
|
|
||||||
|
|
||||||
### Re-negotiation
|
|
||||||
|
|
||||||
Rust emits `stream-selection-changed` (kebab-case, per convention) carrying a new
|
|
||||||
`StreamSelection` plus the position to resume at. The existing
|
|
||||||
`playerSetStreamQuality` response already has exactly the right shape — a tagged
|
|
||||||
`strategy` that tells the caller who reloads, with the backend handling native
|
|
||||||
itself and handing HTML5 a URL for `reloadSource`
|
|
||||||
([index.ts:198](../../src/lib/player/index.ts#L198)). **Extend that; do not
|
|
||||||
invent a second mechanism.** It is the one piece of this that is already right.
|
|
||||||
|
|
||||||
Note the existing wart to preserve or fix deliberately, not accidentally:
|
|
||||||
tauri-specta keeps those response fields snake_case (`new_url`), and the facade
|
|
||||||
comments say so.
|
|
||||||
|
|
||||||
### Phases
|
|
||||||
|
|
||||||
1. **DR-219** `StreamSelection` + `Transport`; delete the `.m3u8` sniff. No
|
|
||||||
behaviour change — pure ownership move, and independently shippable.
|
|
||||||
2. **DR-220** Per-session quality ceiling replacing the `online.rs` static.
|
|
||||||
3. **DR-221** `available` populated from the media source (DR-121's substance).
|
|
||||||
4. **DR-222** Direct-play/direct-stream negotiation via `PlaybackInfo`. This is
|
|
||||||
the phase that unlocks native video and removes the transcode.
|
|
||||||
5. **DR-223** Adaptation, **only if the playlist check says a ladder exists**.
|
|
||||||
Cheapest sufficient design: re-negotiate on sustained throughput drop, reusing
|
|
||||||
the phase-1 re-negotiation path. A local proxy synthesizing a single-variant
|
|
||||||
playlist is a last resort, not a starting point.
|
|
||||||
6. **DR-224** ExoPlayer and mpv consume `StreamSelection` unchanged, proving the
|
|
||||||
contract is player-agnostic rather than HTML5-shaped.
|
|
||||||
|
|
||||||
Phases 1–4 stand on their own merits with no dependency on the ladder question.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- Rendering, compositing, and the Linux native-video work itself. This spec
|
|
||||||
unblocks [linux-native-video-spike.md](linux-native-video-spike.md); it does
|
|
||||||
not contain it.
|
|
||||||
- Replacing hls.js. It stays as the HLS loader for the webview path.
|
|
||||||
- Reimplementing or overriding ExoPlayer's own adaptive selection. See "The line".
|
|
||||||
- The download/capture half of [read-through-media-cache.md](read-through-media-cache.md)
|
|
||||||
(DR-122, DR-124, DR-125), which keeps its own spec.
|
|
||||||
- Audio. The same argument applies, but video is where the transcode cost is.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [ ] The `.m3u8` substring check is gone from `VideoPlayer.svelte` (both sites)
|
|
||||||
and transport comes from the tagged enum.
|
|
||||||
- [ ] `bun run check`, `bun run test`, `bun run format:check`, `bun run lint` pass.
|
|
||||||
- [ ] `cargo fmt` clean, `cargo clippy -D warnings` clean, `bun run test:rust` passes.
|
|
||||||
- [ ] `bun run check:boundary` passes — and the reviewer confirms by reading that
|
|
||||||
no transport/kind decision was reconstructed in `src/`, since the tripwire
|
|
||||||
only catches item-type array literals.
|
|
||||||
- [ ] `bindings.ts` regenerated from Rust, not hand-edited.
|
|
||||||
- [ ] New code carries `// TRACES:` comments; `bun run traces:validate` passes and
|
|
||||||
coverage stays ≥ the CI ratchet.
|
|
||||||
- [ ] The `EXT-X-STREAM-INF` count is recorded in this spec before DR-223 is
|
|
||||||
started or dropped.
|
|
||||||
- [ ] DR-121 is removed from `read-through-media-cache.md` with a pointer here.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- Rust: `PlaybackInfo` fixtures → expected `PlaybackKind`, one per branch
|
|
||||||
(supported container direct-plays; unsupported codec transcodes; a ceiling
|
|
||||||
below the source bitrate transcodes even when the codec is fine).
|
|
||||||
- Rust: `Transport` round-trips through serde with the tag the frontend matches.
|
|
||||||
- Frontend: adapter selection driven by `transport`, including the case a URL
|
|
||||||
ending `.m3u8` is served as `Progressive` — that test fails on today's code,
|
|
||||||
which is the point.
|
|
||||||
- Extend `tauriIntegration.test.ts` for the new command params (camelCase rule).
|
|
||||||
- No test asserts a URL substring.
|
|
||||||
|
|
||||||
## TRACES
|
|
||||||
|
|
||||||
| Piece | Tag |
|
|
||||||
|---|---|
|
|
||||||
| `StreamSelection` / `Transport` | `UR-079 \| DR-219` |
|
|
||||||
| Per-session ceiling | `UR-074 \| DR-220` |
|
|
||||||
| `available` from media source | `UR-079 \| DR-221, DR-121` |
|
|
||||||
| Direct-play negotiation | `UR-079 \| DR-222` |
|
|
||||||
| Adaptation, if built | `UR-079 \| DR-223` |
|
|
||||||
| ExoPlayer/mpv consumers | `UR-003, UR-004 \| DR-224` |
|
|
||||||
|
|
||||||
## Notes for the implementer
|
|
||||||
|
|
||||||
- **Phase 1 is worth doing on its own**, even if everything after it is dropped.
|
|
||||||
It removes a real leak and costs almost nothing.
|
|
||||||
- Do not frame any phase as "no Rust changes required" — that framing is what
|
|
||||||
produced the leak `scoped-search-boundary.md` records.
|
|
||||||
- `ConnectivityMonitor` is the precedent for DR-223: derive network facts from
|
|
||||||
real traffic, never from a side-channel poller.
|
|
||||||
- A parallel Claude session may be active in this repo — `git diff` before
|
|
||||||
"repairing" unexpected changes. Requirement ids in particular moved twice
|
|
||||||
during the writing of this spec.
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
# Spec: Build provenance (git describe + build profile)
|
|
||||||
|
|
||||||
**Status:** Proposed — not started. `src-tauri/build.rs` still contains only
|
|
||||||
`tauri_build::build()`, and nothing reports a version over IPC. Note that
|
|
||||||
`scripts/set-version.sh` has since landed, which changes the "three hand-bumped
|
|
||||||
files" premise below: versions are now stamped from one place.
|
|
||||||
**Requirements:** ⚠️ the suggested id **DR-093 has since been allocated** to the
|
|
||||||
traceability coverage gate — allocate a fresh id (DR-215 or later) on
|
|
||||||
implementation. Build provenance surfaced in-app and in logs; no UR — this is a
|
|
||||||
diagnostic capability, not a user feature
|
|
||||||
**UX spec:** n/a — adds an About block to Settings; no new flow
|
|
||||||
**Supersedes / revises:** —
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Make every build say exactly what it is. Today a running JellyTau reports no
|
|
||||||
version at all — not in the UI, not in the logs — and the only version string in
|
|
||||||
the tree is the hand-maintained `0.2.0` duplicated across three files.
|
|
||||||
|
|
||||||
This adds a `build.rs`-generated provenance string (`git describe` + short SHA +
|
|
||||||
dirty flag + debug/release profile), exposes it over IPC, and renders it in a new
|
|
||||||
Settings › About block. It also removes one of the three hand-bumped version
|
|
||||||
files.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
The concrete problem: when a user reports "the equalizer does nothing on my
|
|
||||||
device" — which is a live risk for v0.2.0, whose Android audio settings are not
|
|
||||||
yet device-verified — there is currently no way to tell which build they are
|
|
||||||
running. Tag? Master? A local debug build from three weeks ago? The bug report
|
|
||||||
cannot distinguish them.
|
|
||||||
|
|
||||||
Two smaller irritations this also fixes:
|
|
||||||
|
|
||||||
- **Debug builds masquerade as releases.** `0.2.0` is `0.2.0` whether it came
|
|
||||||
from a tagged release or `bun run tauri dev`.
|
|
||||||
- **Three files carry the version.** `package.json`, `src-tauri/Cargo.toml` and
|
|
||||||
`src-tauri/tauri.conf.json` must be bumped in lockstep; the release checklist
|
|
||||||
exists partly to stop them drifting.
|
|
||||||
|
|
||||||
### What this deliberately does *not* do
|
|
||||||
|
|
||||||
**The canonical version stays hand-bumped in `Cargo.toml`.** Cargo requires a
|
|
||||||
literal semver string at manifest-parse time and cannot derive it from git. The
|
|
||||||
same is true of `tauri.conf.json`. Attempting to source the *release* version
|
|
||||||
from a tag trades a scripted, reviewable bump for a fragile build-time
|
|
||||||
dependency that breaks in exactly the environment we care most about (CI, in
|
|
||||||
Docker, from a shallow clone).
|
|
||||||
|
|
||||||
So: **the release version is authored; the build provenance is derived.** They
|
|
||||||
answer different questions — "what release is this?" versus "what commit is this
|
|
||||||
binary actually built from?" — and only the second benefits from git.
|
|
||||||
|
|
||||||
## Layer assignment
|
|
||||||
|
|
||||||
| Logic / responsibility | Layer | Why it belongs there |
|
|
||||||
|------------------------|-------|----------------------|
|
|
||||||
| Capturing git describe / SHA / dirty state at compile time | Rust (`build.rs`) | Only the Rust build has a compile step that can shell out to git and bake the result into the binary. A frontend equivalent would report the *dev server's* state, not the shipped binary's. |
|
|
||||||
| Degrading to a sentinel when git is unavailable | Rust (`build.rs`) | Build-environment concern. Must never fail the build — CI runs in Docker from a shallow clone. |
|
|
||||||
| Release version (`0.2.0`) | Rust (`Cargo.toml`, authored) | Domain fact about the product, not derivable from the environment. |
|
|
||||||
| Deciding *what a build is* (release / dev / dirty) | Rust | Domain classification. The frontend must not infer "this is a dev build" from a string shape — it renders what it is told. |
|
|
||||||
| Rendering the About block, copy-to-clipboard | Frontend | Pure presentation. |
|
|
||||||
|
|
||||||
Borderline row: the release/dev/dirty classification could be done in the
|
|
||||||
frontend by pattern-matching the describe string. It goes to Rust because that is
|
|
||||||
a *rule about what constitutes a release build*, and it would have to change if
|
|
||||||
the tagging scheme changed — the litmus test in the template puts that in Rust.
|
|
||||||
Send a typed enum, not a string for the frontend to parse.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### `build.rs`
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn main() {
|
|
||||||
emit_build_provenance();
|
|
||||||
tauri_build::build()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_build_provenance() {
|
|
||||||
let describe = std::process::Command::new("git")
|
|
||||||
.args(["describe", "--tags", "--always", "--dirty"])
|
|
||||||
.output()
|
|
||||||
.ok()
|
|
||||||
.filter(|o| o.status.success())
|
|
||||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
|
||||||
|
|
||||||
println!("cargo:rustc-env=JELLYTAU_GIT_DESCRIBE={describe}");
|
|
||||||
|
|
||||||
// Rebuild when HEAD moves or a ref is written, so the string does not go
|
|
||||||
// stale across commits. Guarded: these paths do not exist in a git-less
|
|
||||||
// source tarball, and emitting rerun-if-changed for a missing path would
|
|
||||||
// force a rebuild every time.
|
|
||||||
for p in [".git/HEAD", ".git/refs"] {
|
|
||||||
if std::path::Path::new("../").join(p).exists() {
|
|
||||||
println!("cargo:rerun-if-changed=../{p}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
🔴 **`build.rs` must never fail the build.** Every git call is
|
|
||||||
`.ok()`-swallowed; a missing git binary, a shallow clone, or a source tarball all
|
|
||||||
yield `"unknown"`. A build that breaks because git is absent would be a worse bug
|
|
||||||
than the one this fixes.
|
|
||||||
|
|
||||||
Note the `../` prefixes: `build.rs` runs with CWD at `src-tauri/`, so the repo's
|
|
||||||
`.git` is one level up.
|
|
||||||
|
|
||||||
### The provenance type
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// TRACES: DR-093
|
|
||||||
#[derive(specta::Type, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct BuildInfo {
|
|
||||||
/// Authored release version (Cargo.toml).
|
|
||||||
pub version: String,
|
|
||||||
/// `git describe --tags --always --dirty`, or "unknown".
|
|
||||||
pub git_describe: String,
|
|
||||||
/// What kind of build this is — classified in Rust, not inferred by the UI.
|
|
||||||
pub kind: BuildKind,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// TRACES: DR-093
|
|
||||||
#[derive(specta::Type, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub enum BuildKind {
|
|
||||||
/// Built from a clean, exactly-tagged commit in release mode.
|
|
||||||
Release,
|
|
||||||
/// Release-mode build that is not on a clean tag (e.g. master, or dirty).
|
|
||||||
Untagged,
|
|
||||||
/// debug_assertions build.
|
|
||||||
Development,
|
|
||||||
/// Git state unavailable at build time.
|
|
||||||
Unknown,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Classification:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
let kind = if cfg!(debug_assertions) {
|
|
||||||
BuildKind::Development
|
|
||||||
} else if describe == "unknown" {
|
|
||||||
BuildKind::Unknown
|
|
||||||
} else if describe.contains('-') { // "v0.2.0-3-gcb79a37" or "...-dirty"
|
|
||||||
BuildKind::Untagged
|
|
||||||
} else {
|
|
||||||
BuildKind::Release
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Command
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// TRACES: DR-093
|
|
||||||
#[tauri::command]
|
|
||||||
#[specta::specta]
|
|
||||||
pub fn get_build_info() -> BuildInfo { … }
|
|
||||||
```
|
|
||||||
|
|
||||||
No parameters, so the camelCase param rule does not apply; the struct fields do
|
|
||||||
need `#[serde(rename_all = "camelCase")]` (above). Regenerate `bindings.ts`.
|
|
||||||
|
|
||||||
Also log the provenance once at startup, next to the existing init logging —
|
|
||||||
that is what makes a user-submitted log file self-identifying, which is most of
|
|
||||||
the value.
|
|
||||||
|
|
||||||
### Settings › About
|
|
||||||
|
|
||||||
A new block at the bottom of `src/routes/settings/+page.svelte`, rendering
|
|
||||||
version, describe string, and a badge for non-release builds. One
|
|
||||||
copy-to-clipboard button that yields a paste-ready block for bug reports:
|
|
||||||
|
|
||||||
```
|
|
||||||
JellyTau 0.2.0 (v0.2.0-3-gcb79a37-dirty, development)
|
|
||||||
linux x86_64
|
|
||||||
```
|
|
||||||
|
|
||||||
Platform/arch come from the existing Tauri APIs; do not shell out.
|
|
||||||
|
|
||||||
### Removing one version file
|
|
||||||
|
|
||||||
`tauri.conf.json`'s `"version"` field can be omitted, in which case Tauri falls
|
|
||||||
back to the Cargo version. That takes the bump from three files to two.
|
|
||||||
|
|
||||||
**Verify before adopting**: confirm the Android `versionName`/`versionCode` and
|
|
||||||
the NSIS installer version still resolve correctly with the field absent —
|
|
||||||
Android packaging in particular reads the Tauri config. If either regresses,
|
|
||||||
keep the field and drop this part; it is a convenience, not the point of the
|
|
||||||
spec.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- Deriving the *release* version from git tags (see Motivation).
|
|
||||||
- A build-time timestamp. It defeats reproducible builds and adds little over
|
|
||||||
the commit SHA.
|
|
||||||
- CI provenance/attestation, SBOM, signing.
|
|
||||||
- Displaying the Jellyfin server version (separate concern, already available
|
|
||||||
from `/System/Info`).
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [ ] `cargo build` succeeds with git absent, from a shallow clone, and from a source tarball with no `.git` — yielding `"unknown"` in each case, never a build failure.
|
|
||||||
- [ ] A tagged clean release build reports `BuildKind::Release`; `bun run tauri dev` reports `Development`; a dirty tree reports `Untagged` (release mode) with `-dirty` in the describe string.
|
|
||||||
- [ ] The describe string changes after a new commit without a manual `cargo clean` (rerun-if-changed works).
|
|
||||||
- [ ] Provenance is logged once at startup.
|
|
||||||
- [ ] Settings › About renders version + describe + build-kind badge, with working copy-to-clipboard.
|
|
||||||
- [ ] 🔴 CI checkouts that build a shippable artifact set `fetch-depth: 0`, or their artifacts are knowingly stamped `unknown`. Currently only `publish-docs.yml` sets it; `build-release.yml` has five checkouts and `build-and-test.yml` two, all of which would report `unknown` as-is.
|
|
||||||
- [ ] **No toolchain installed in CI** — git is already present in the builder image; nothing new is added.
|
|
||||||
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass.
|
|
||||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
|
||||||
- [ ] `bindings.ts` regenerated.
|
|
||||||
- [ ] DR-093 allocated in `requirements.md`; new code carries `// TRACES:`.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
**Rust**: the classification is pure and must be extracted from the command as
|
|
||||||
`classify_build(describe: &str, debug: bool) -> BuildKind` so it can be tested
|
|
||||||
directly. Cover: `"v0.2.0"` → `Release`; `"v0.2.0-3-gcb79a37"` → `Untagged`;
|
|
||||||
`"v0.2.0-dirty"` → `Untagged`; `"unknown"` → `Unknown`; `debug = true` → always
|
|
||||||
`Development` regardless of describe.
|
|
||||||
|
|
||||||
`build.rs` itself is not unit-testable. Verify its failure path manually by
|
|
||||||
building with `PATH` stripped of git, and from a `git archive` tarball — both
|
|
||||||
must succeed with `"unknown"`.
|
|
||||||
|
|
||||||
**Frontend**: assert the About block renders each `BuildKind` correctly, and that
|
|
||||||
it renders the backend-supplied kind rather than re-deriving it from the string
|
|
||||||
(a test that passes a `Release` kind with a `-dirty` describe and asserts the
|
|
||||||
badge follows the *kind* would catch that regression).
|
|
||||||
|
|
||||||
## TRACES
|
|
||||||
|
|
||||||
- `build.rs` provenance emission → `// TRACES: | DR-093`
|
|
||||||
- `BuildInfo` / `BuildKind` / `classify_build` → `// TRACES: | DR-093`
|
|
||||||
- `get_build_info` command → `// TRACES: | DR-093`
|
|
||||||
- Settings About block → `// TRACES: | DR-093`
|
|
||||||
- `classify_build` tests → `UT-BUILD-1`
|
|
||||||
- Allocate **DR-093** in `requirements.md` ("Build provenance: git describe and
|
|
||||||
build profile surfaced in-app and in logs"). Next free DR at time of writing
|
|
||||||
is DR-093.
|
|
||||||
|
|
||||||
## Notes for the implementer
|
|
||||||
|
|
||||||
- Do the `build.rs` + command + logging first; the About UI is the smaller half
|
|
||||||
and the logging alone delivers most of the diagnostic value.
|
|
||||||
- The `fetch-depth: 0` change is the easiest part to forget and the one that
|
|
||||||
makes CI artifacts useless if missed — it is why that acceptance box is
|
|
||||||
flagged. Weigh it per workflow: test-only jobs do not need it.
|
|
||||||
- Do not add a build timestamp "while you are in there" — see Out of scope.
|
|
||||||
- A parallel Claude session may be active — `git diff` before "repairing"
|
|
||||||
unexpected changes.
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
# Spec: Desktop native video — mpv renders the picture, everywhere
|
|
||||||
|
|
||||||
**Status:** Proposed
|
|
||||||
**Requirements:** UR-080 (new) → DR-231 … DR-237 (new); IR-033 (new)
|
|
||||||
**UX spec:** n/a — nothing about the player's appearance changes. What changes is
|
|
||||||
what is behind the controls.
|
|
||||||
**Supersedes / revises:** consumes and closes
|
|
||||||
[linux-native-video-spike.md](linux-native-video-spike.md), whose gates
|
|
||||||
authorised exactly this spec and nothing more. Settles finding 2 of
|
|
||||||
[playback-backend-unification.md](playback-backend-unification.md) on the
|
|
||||||
desktop; finding 3 was already settled by DR-229. Absorbs the video half of what
|
|
||||||
[windows-native-audio-backend.md](windows-native-audio-backend.md) leaves open.
|
|
||||||
**Depends on:** backend-owned stream selection (DR-225 … DR-230), the branch
|
|
||||||
below this one. mpv is a *consumer* of `StreamSelection`, never a second place to
|
|
||||||
decide what to play.
|
|
||||||
|
|
||||||
**Destination on completion:**
|
|
||||||
[05-platform-backends.md](../architecture/05-platform-backends.md) — a "Native
|
|
||||||
Video Compositing (Desktop)" section beside the existing Android one, which this
|
|
||||||
mirrors; and [01-rust-backend.md](../architecture/01-rust-backend.md) — the
|
|
||||||
device profile becomes renderer-dependent, beside the stream-selection section.
|
|
||||||
**The spike is deleted in the same commit**, its three traps and its
|
|
||||||
hardware-decode table folded in; they are the durable half.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
mpv decodes and draws video on **every desktop platform**, composited beneath the
|
|
||||||
transparent webview, exactly as Android already does with ExoPlayer. The HTML5
|
|
||||||
`<video>` path and hls.js are then **deleted**, not merely bypassed.
|
|
||||||
|
|
||||||
The user-visible change is that most video stops being re-encoded by the server
|
|
||||||
before it can be watched. The change for whoever maintains this is that video
|
|
||||||
goes from three renderers to two.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
### The transcode is a decoder constraint, not a rendering one
|
|
||||||
|
|
||||||
Desktop video goes through an h264 HLS transcode because the picture is drawn by
|
|
||||||
a WebKitGTK `<video>` element, and that element decodes little else. The device
|
|
||||||
profile therefore claims `h264` alone. That is not a statement about the machine
|
|
||||||
— the same machine runs mpv, which decodes essentially everything in the library
|
|
||||||
— it is a statement about which widget is holding the frame.
|
|
||||||
|
|
||||||
DR-228 made the cost measurable. Over 40 items negotiated against the development
|
|
||||||
server:
|
|
||||||
|
|
||||||
| Profile | Direct play |
|
|
||||||
|---|---|
|
|
||||||
| Desktop / WebKitGTK — `h264` only, 2ch | **7%** |
|
|
||||||
| Android / ExoPlayer — `h264,hevc,vp8,vp9,av1,mpeg4` + `ac3,eac3`, 6ch | **85%** |
|
|
||||||
|
|
||||||
The sampled library is ~80% hevc. **Those rows differ only by which component
|
|
||||||
decodes.**
|
|
||||||
|
|
||||||
Moving the picture to mpv is what lets the desktop row claim what the machine
|
|
||||||
can actually do, and that — not the compositing — is the product.
|
|
||||||
|
|
||||||
> **The 85% is a ceiling, not a shipped result.** It was measured with a profile
|
|
||||||
> containing `ac3,eac3`. The Android device later used for verification reports
|
|
||||||
> neither in its `MediaCodecList` — no Dolby licence, normal for a tablet — so
|
|
||||||
> eac3 content, about a third of the sampled library, correctly transcodes there.
|
|
||||||
> Realising any of this depends on DR-234, deriving the profile from the renderer
|
|
||||||
> rather than from the platform, which is why that requirement is load-bearing
|
|
||||||
> and not tidy-up.
|
|
||||||
|
|
||||||
### One desktop video path, not two
|
|
||||||
|
|
||||||
This is why the spec covers Windows rather than stopping at Linux.
|
|
||||||
|
|
||||||
Today video has **three** renderers: ExoPlayer, the WebKitGTK `<video>` element,
|
|
||||||
and (on Android, via the opt-out) that same element again. A Linux-only version
|
|
||||||
of this work would make it four, permanently: mpv on Linux, HTML5 on Windows,
|
|
||||||
ExoPlayer on Android, plus hls.js underneath the HTML5 one. Every seek strategy,
|
|
||||||
every track switch, every quality change, every lifecycle bug would then have one
|
|
||||||
more place to be got right — and the HTML5 path would survive indefinitely
|
|
||||||
because *something* would still need it.
|
|
||||||
|
|
||||||
Finishing the job removes that: **mpv on desktop, ExoPlayer on Android**, and
|
|
||||||
`hls.js`, `html5Adapter.ts`, `videoLoaderFor` and the webview video element all
|
|
||||||
go. The maintenance win is the reason Windows is in this spec and not in a
|
|
||||||
follow-up that never gets written.
|
|
||||||
|
|
||||||
### Three blockers are gone
|
|
||||||
|
|
||||||
1. **Compositing works, including Wayland.** The spike ran all six gates; the
|
|
||||||
2024 "not possible on Wayland at all" claim is out of date when the render API
|
|
||||||
is used instead of foreign-window embedding.
|
|
||||||
2. **There is no ABR to lose.** DR-229: the server's master playlist carries one
|
|
||||||
`EXT-X-STREAM-INF`. hls.js was demuxing, not adapting.
|
|
||||||
3. **A direct-play path exists.** It did not when the spike was written. DR-228
|
|
||||||
built it; DR-230 proved the contract is player-agnostic.
|
|
||||||
|
|
||||||
And on Windows specifically, `tauri-plugin-libmpv` lists Windows as its **fully
|
|
||||||
tested** platform — the inverse of the Linux situation the spike had to
|
|
||||||
disprove. The embedding difficulty was always WebKitGTK-specific.
|
|
||||||
|
|
||||||
## Layer assignment
|
|
||||||
|
|
||||||
| Logic / responsibility | Layer | Why it belongs there |
|
|
||||||
|---|---|---|
|
|
||||||
| **Which codecs this device can decode** | **Rust** | Domain: it is the input to Jellyfin's `PlaybackInfo` negotiation. It stops being a property of the *platform* and becomes a property of *the renderer in use* — see "The structural change". |
|
|
||||||
| Which backend renders video | **Rust** | Rust already owns this (`use_html5_element` / `VideoBackend`). It stops being a `cfg!` constant and becomes a runtime fact. |
|
|
||||||
| What stream to play (direct / remux / transcode, transport, ceiling) | **Rust — already decided** | DR-225. mpv consumes `StreamSelection`. Re-deriving any of it in a new backend would be the defect DR-225 exists to remove, restated. |
|
|
||||||
| Creating the GL surface, reparenting the webview, owning the render context | **Rust (platform layer)** | Native window and GL-context lifetime. Not presentation, and not expressible above the IPC boundary at all. |
|
|
||||||
| Render-context ↔ GL-context lifetime binding | **Rust** | A correctness invariant over native resources. DR-232. |
|
|
||||||
| Frame pacing (update callback, `report_swap`) | **Rust** | Timing against the compositor; mpv's own contract. |
|
|
||||||
| Hardware-decode selection | **Rust** | A capability question about the machine, answered from what mpv reports it actually selected. |
|
|
||||||
| Z-order of controls over video, overlay chrome, letterbox colour | **Frontend / mpv** | Presentation. Controls already draw over a transparent webview on Android; mpv paints its own letterbox bars (better than the Android equivalent, which shipped DR-194 as a defect). |
|
|
||||||
| Whether the surface is visible right now | **Frontend** | `nativeVideoActive` already exists and toggles `data-native-video`. Unchanged. |
|
|
||||||
|
|
||||||
### The structural change
|
|
||||||
|
|
||||||
Everything above is routine except one row, and it carries the whole benefit.
|
|
||||||
|
|
||||||
`video_codecs` in `build_device_profile` is a **compile-time constant per
|
|
||||||
platform**:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[cfg(all(not(target_os = "android"), target_os = "linux"))]
|
|
||||||
let (video_codecs, audio_codecs) = ("h264".to_string(), "aac,mp3,opus,…");
|
|
||||||
```
|
|
||||||
|
|
||||||
That is correct only while a build has exactly one video renderer. It must be
|
|
||||||
derived from **which renderer will decode this stream**, which is runtime state.
|
|
||||||
|
|
||||||
It looks like configuration and is not: it is the input that decides whether the
|
|
||||||
server re-encodes, it changes when Jellyfin's API or our renderer changes, and
|
|
||||||
getting it wrong fails *silently* — a claimed codec the renderer cannot decode is
|
|
||||||
a black picture or silence, which is DR-148 and DR-228's audio override already.
|
|
||||||
|
|
||||||
**Write this against "the active video renderer", never `cfg!(target_os)`.** It
|
|
||||||
is the single piece that must not be Linux-shaped, because phase 2 reuses it
|
|
||||||
unchanged.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### Backend and compositing (DR-231, IR-033)
|
|
||||||
|
|
||||||
An `MpvVideoBackend` beside the existing `MpvBackend` (audio). The mpv side —
|
|
||||||
render context, FBO, update callback, hwdec — is **shared**; only the surface
|
|
||||||
differs per platform:
|
|
||||||
|
|
||||||
| Platform | Surface | Status |
|
|
||||||
|---|---|---|
|
|
||||||
| Linux (X11 + Wayland) | `gdk_cairo_draw_from_gl()` in the default vbox's `draw` handler, over a `GdkGLContext` on its `GdkWindow`. No reparenting — see below | Render path proven by the spike; the *overlay* approach it used is rejected |
|
|
||||||
| Windows | Native HWND child beneath a transparent WebView2 | Phase 2 |
|
|
||||||
|
|
||||||
`vo=libmpv` plus `mpv_render_context_create` with `MPV_RENDER_PARAM_OPENGL_FBO`.
|
|
||||||
Webview transparency via `with_transparent(true)` — no window-level transparency;
|
|
||||||
the spike showed it is neither used nor needed.
|
|
||||||
|
|
||||||
**G1's untested half failed, and the design changed because of it.**
|
|
||||||
|
|
||||||
Reparenting Tauri's webview into a `GtkOverlay` attaches cleanly and then aborts
|
|
||||||
the process on the first click. `tauri-runtime-wry` connects a
|
|
||||||
button-press handler to the webview that walks a hard-coded path:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
webview.parent() // "This one should be GtkBox"
|
|
||||||
.parent() // ...and this one the GtkWindow
|
|
||||||
.downcast::<gtk::Window>().unwrap()
|
|
||||||
```
|
|
||||||
|
|
||||||
An overlay makes that chain `webview → GtkOverlay → GtkBox`, the downcast fails,
|
|
||||||
and the panic is non-unwinding so it kills the app. Nothing in configuration
|
|
||||||
avoids it: on Linux `attach_resize_handler` is called **unconditionally** (the
|
|
||||||
Windows equivalent is guarded by `is_decorated()`), and the decoration check that
|
|
||||||
would make the handler inert runs *after* the unwrap.
|
|
||||||
|
|
||||||
**So the webview is not moved at all.** mpv draws into the *default vbox's own
|
|
||||||
`draw` handler* instead, via `gdk_cairo_draw_from_gl()` over a `GdkGLContext`
|
|
||||||
created on that widget's `GdkWindow`. GTK3 draws a container before its children,
|
|
||||||
so the webview composites on top for free — the same z-order the overlay was for,
|
|
||||||
without touching the widget tree Tauri walks.
|
|
||||||
|
|
||||||
That is strictly better than the overlay it replaces: no reparent, no extra
|
|
||||||
widget, and the arrangement cannot be broken by a Tauri upgrade that assumes its
|
|
||||||
own layout. It is also why "the surface attached successfully" is not the gate —
|
|
||||||
a click is.
|
|
||||||
|
|
||||||
Three traps from the spike, each of which cost a debugging cycle and each of
|
|
||||||
which looks like a platform limitation and is not:
|
|
||||||
|
|
||||||
1. **`LC_NUMERIC` must be reset *after* `gtk::init()`.** mpv refuses to start
|
|
||||||
under a non-C numeric locale. `mpv_backend.rs` already handles this but has no
|
|
||||||
GTK init in front of it; here `gtk::init()` applies the user's locale
|
|
||||||
afterwards and `mpv_create` returns null.
|
|
||||||
2. **libepoxy exports GL entry points as *data* symbols.** There is no `glFoo`
|
|
||||||
function — there is `epoxy_glFoo`, a variable holding a lazily-resolving
|
|
||||||
pointer. `get_proc_address` must return the pointer **stored at** that symbol;
|
|
||||||
returning the symbol's own address makes mpv jump into non-executable data and
|
|
||||||
take SIGSEGV on the first GL call. The `epoxy` crate does this correctly but is
|
|
||||||
unusable — its `gl_generator` dependency pulls a yanked `xml-rs`.
|
|
||||||
3. **Frame pacing is not optional and its symptom misleads.** See DR-233.
|
|
||||||
|
|
||||||
### Render-context lifetime (DR-232) — the crash defence
|
|
||||||
|
|
||||||
The spike's one unexplained SIGSEGV landed in a *decoder* thread with no Tauri,
|
|
||||||
GTK or GL frame in the stack, and three plausible causes failed to reproduce it
|
|
||||||
across ~13 minutes of targeted stress.
|
|
||||||
|
|
||||||
What is **not** unexplained is that the spike had no defence: it never calls
|
|
||||||
`mpv_render_context_free` and never tears down on `unrealize`, so nothing stopped
|
|
||||||
the GL context being recreated beneath the render context. That is DR-184 on
|
|
||||||
Android restated — a surface outliving its player.
|
|
||||||
|
|
||||||
Built as a requirement in its own right, not as a fix for a crash we cannot yet
|
|
||||||
reproduce:
|
|
||||||
|
|
||||||
- Render context created on `realize`, freed on `unrealize`, same thread, before
|
|
||||||
the GL context goes away.
|
|
||||||
- The update callback is unregistered **before** the context is freed, so a
|
|
||||||
callback cannot land on a freed context.
|
|
||||||
- Playback teardown and surface teardown are ordered, not racing.
|
|
||||||
|
|
||||||
If the crash recurs after this, it is a different bug and the likeliest cause is
|
|
||||||
out of the search space. If it does not, we needed this anyway.
|
|
||||||
|
|
||||||
### Frame pacing (DR-233)
|
|
||||||
|
|
||||||
Register `mpv_render_context_set_update_callback`; redraw only when it reports a
|
|
||||||
frame ready; call `mpv_render_context_report_swap` after each render.
|
|
||||||
|
|
||||||
Recorded because the failure mode is a trap: driving `queue_render()` off the
|
|
||||||
frame clock every tick without reporting the swap leaves mpv nothing to time
|
|
||||||
against. It looks fine in a window and **judders at fullscreen**, which reads as
|
|
||||||
a compositing or GPU limit and is neither.
|
|
||||||
|
|
||||||
### Renderer-dependent device profile (DR-234)
|
|
||||||
|
|
||||||
`build_device_profile` takes the active video renderer and derives the codec
|
|
||||||
lists from it:
|
|
||||||
|
|
||||||
| Renderer | Video codecs | Audio (video direct play) | Channels |
|
|
||||||
|---|---|---|---|
|
|
||||||
| mpv (desktop native) | `h264,hevc,vp8,vp9,av1,mpeg4` | platform list incl. `ac3,eac3` where the sink can voice it | from the audio route |
|
|
||||||
| WebKitGTK `<video>` | `h264` | webview-decodable set only | 2 |
|
|
||||||
| ExoPlayer (Android) | unchanged | unchanged | unchanged |
|
|
||||||
|
|
||||||
The existing `video_audio_codecs()` narrowing exists because *the webview decodes
|
|
||||||
a narrower audio set than the platform*. With mpv decoding, that no longer
|
|
||||||
applies to the video path — but the multichannel bound still does, since a 5.1
|
|
||||||
track direct-played into a 2-channel sink is silence or inaudible dialogue. Both
|
|
||||||
constraints stay, sourced from the renderer rather than assumed.
|
|
||||||
|
|
||||||
**This is what converts the 7% figure upward** (toward, not necessarily to, the 85% ceiling — see the caveat above), and it is also the change most able to break
|
|
||||||
playback silently — so it lands after compositing is proven, covered by the
|
|
||||||
DR-228 override tests.
|
|
||||||
|
|
||||||
### Deleting the webview video path (DR-235)
|
|
||||||
|
|
||||||
`get_player_status` stops reporting `use_html5_element: true` on desktop;
|
|
||||||
`supports_native_video` becomes true there.
|
|
||||||
|
|
||||||
Deletion is staged, because a path cannot be removed while a shipped platform
|
|
||||||
still needs it:
|
|
||||||
|
|
||||||
| Phase | Linux | Windows | HTML5 video path |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | mpv | HTML5 | alive — Windows needs it |
|
|
||||||
| 2 | mpv | mpv | alive but unreached |
|
|
||||||
| 3 | mpv | mpv | **deleted**, with hls.js |
|
|
||||||
|
|
||||||
Phase 3 is a real phase with its own acceptance criterion, not a "later". The
|
|
||||||
whole maintenance argument for including Windows collapses if the fork survives.
|
|
||||||
|
|
||||||
Android keeps ExoPlayer and keeps the webview as its documented opt-out; the
|
|
||||||
`<audio>` element and the background-audio handoff are untouched throughout.
|
|
||||||
|
|
||||||
**What happens when mpv fails to initialise.** With no HTML5 path there is no
|
|
||||||
silent fallback, and inventing one resurrects what we deleted. The
|
|
||||||
graceful-backend-init principle applies as written: fall back to the no-op
|
|
||||||
backend, emit `backend-init-failed`, and surface a real error rather than a black
|
|
||||||
rectangle. An honest failure beats a hidden downgrade to the transcode we are
|
|
||||||
trying to stop paying for.
|
|
||||||
|
|
||||||
### Hardware decode (DR-236)
|
|
||||||
|
|
||||||
The spike established the load-bearing fact: **hardware decode works through the
|
|
||||||
render API** (`hwdec-current` reported `nvdec-copy` on the discrete GPU), so the
|
|
||||||
direct-play prize is not traded for software decoding.
|
|
||||||
|
|
||||||
Policy is decided from what mpv reports it *selected*, never from what it was
|
|
||||||
asked for:
|
|
||||||
|
|
||||||
- Prefer zero-copy VA-API on the integrated GPU where the driver is present.
|
|
||||||
- `auto` reached for the discrete GPU in **copy-back** mode on a hybrid
|
|
||||||
Intel+NVIDIA laptop — the least efficient hardware path — so `auto` is a
|
|
||||||
fallback, not the default.
|
|
||||||
- `vaapi` silently fell back to software on the spike box because `vainfo` was
|
|
||||||
absent. A missing driver must be detected and logged, not mistaken for a
|
|
||||||
compositing limit.
|
|
||||||
- Log `hwdec-current` at start-up; knowing what was actually chosen is the whole
|
|
||||||
diagnostic value.
|
|
||||||
|
|
||||||
### Windows: what phase 2 actually costs (DR-237)
|
|
||||||
|
|
||||||
Not hidden, because it is the part most likely to be underestimated:
|
|
||||||
|
|
||||||
- **The surface is different code.** WebView2 in an HWND, not GTK. A transparent
|
|
||||||
WebView2 over a native child window is a solved arrangement, but DR-231's
|
|
||||||
Linux surface does not transfer. Everything else does.
|
|
||||||
- **libmpv is currently a Linux-only dependency**, and Windows is
|
|
||||||
**cross-compiled from Linux** via `x86_64-pc-windows-msvc` + `cargo-xwin`. Phase
|
|
||||||
2 must source a Windows libmpv (DLL + import library) into that cross-build and
|
|
||||||
ship the DLL in the NSIS bundle.
|
|
||||||
- **LGPL obligations follow the DLL.** DR-216 already records them for Linux:
|
|
||||||
keep the linkage dynamic, ship libmpv's licence text with any bundle carrying
|
|
||||||
it. The Windows bundle inherits both.
|
|
||||||
- **`bun run test:rust` and CI must still build.** Per the CI rule, any tool this
|
|
||||||
needs goes into the builder image and is pushed — never installed at job time.
|
|
||||||
|
|
||||||
Windows also gains a native *audio* decoder as a side effect, which is what
|
|
||||||
[windows-native-audio-backend.md](windows-native-audio-backend.md) wants and
|
|
||||||
cannot currently have. If that spec lands first, phase 2 inherits its build work
|
|
||||||
and shrinks to the surface.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- **Android.** Unchanged in every respect.
|
|
||||||
- **macOS.** Not a shipped target. If it becomes one it joins phase 2's shape.
|
|
||||||
- **Audio backends.** mpv already plays audio on Linux; this adds a video
|
|
||||||
renderer beside it. Windows audio is its own spec.
|
|
||||||
- **HDR, tone mapping, multi-window.** Not exercised by the spike at all.
|
|
||||||
- **Re-deciding what stream to play.** DR-225 owns that. If this spec finds
|
|
||||||
itself choosing a URL, something has gone wrong.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
**Phase 1 — Linux**
|
|
||||||
|
|
||||||
- [ ] Tauri's own webview reparents into the overlay (the untested half of G1),
|
|
||||||
on X11 **and** Wayland.
|
|
||||||
- [ ] Video plays, seeks and switches audio track in mpv, with the Svelte
|
|
||||||
controls composited over it and alpha blending intact.
|
|
||||||
- [ ] The render context is freed on `unrealize` and the update callback
|
|
||||||
unregistered before the free; a test demonstrates the ordering.
|
|
||||||
- [ ] A direct-play negotiation returns `DirectPlay` for an hevc source that
|
|
||||||
today returns `Transcode`, and it plays.
|
|
||||||
- [ ] Direct-play rate over the same 40-item sample rises from 7% toward the
|
|
||||||
Android figure. **Record the number.**
|
|
||||||
- [ ] mpv init failure emits `backend-init-failed` and surfaces an error rather
|
|
||||||
than falling back to a transcode.
|
|
||||||
- [ ] `hwdec-current` is logged and is not copy-back where zero-copy is available.
|
|
||||||
- [ ] A soak covering seek, track switch and fullscreen runs clean for an agreed
|
|
||||||
duration. **The spike's SIGSEGV is why this is a criterion.**
|
|
||||||
|
|
||||||
**Phase 2 — Windows**
|
|
||||||
|
|
||||||
- [ ] libmpv links in the `cargo-xwin` cross-build; the DLL and its licence ship
|
|
||||||
in the NSIS bundle; any new tool lives in the builder image, not in a CI step.
|
|
||||||
- [ ] Video plays composited under a transparent WebView2.
|
|
||||||
- [ ] The device profile, lifetime and hwdec code are **reused, not
|
|
||||||
reimplemented** — a reviewer confirms no `cfg!(target_os = "linux")` guards
|
|
||||||
them.
|
|
||||||
|
|
||||||
**Phase 3 — deletion**
|
|
||||||
|
|
||||||
- [ ] `use_html5_element` is false on every desktop platform.
|
|
||||||
- [ ] `hls.js` is gone from `package.json`; `html5Adapter.ts`, `videoLoaderFor`
|
|
||||||
and the `<video>` element are deleted; Android's opt-out and the
|
|
||||||
background-audio `<audio>` path still work.
|
|
||||||
|
|
||||||
**Throughout**
|
|
||||||
|
|
||||||
- [ ] `bun run check`, `bun run test`, `bun run format:check`, `bun run lint` pass.
|
|
||||||
- [ ] `cargo fmt` clean, `cargo clippy -D warnings` clean, `bun run test:rust` passes.
|
|
||||||
- [ ] `bun run check:boundary` passes, and a reviewer confirms no stream decision
|
|
||||||
was reconstructed in the new backend.
|
|
||||||
- [ ] `bindings.ts` regenerated from Rust.
|
|
||||||
- [ ] `bun run traces:validate` passes; coverage stays ≥ the CI ratchet.
|
|
||||||
- [ ] The spike and this spec are folded into
|
|
||||||
[05-platform-backends.md](../architecture/05-platform-backends.md) and both
|
|
||||||
deleted in the same commit.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- **Rust, pure:** the device profile per renderer — mpv claims hevc, the webview
|
|
||||||
does not, the multichannel bound survives both. The DR-234 table as a
|
|
||||||
table-driven test.
|
|
||||||
- **Rust, pure:** `PlaybackInfo` fixtures that transcode under the webview
|
|
||||||
profile and direct-play under the mpv profile — the direct-play conversion as a unit
|
|
||||||
test, not only as a measurement.
|
|
||||||
- **Rust:** teardown ordering — callback unregistered before context freed, freed
|
|
||||||
before GL context destroyed. Structure it so the ordering is assertable without
|
|
||||||
a live GL context.
|
|
||||||
- **Frontend:** no desktop path selects an HTML5 video adapter. After phase 3,
|
|
||||||
the adapter does not exist and the test goes with it.
|
|
||||||
- **Manual / soak:** the criterion above. The spike's automated fullscreen and
|
|
||||||
resize soaks are reusable and already written.
|
|
||||||
|
|
||||||
## TRACES
|
|
||||||
|
|
||||||
| Piece | Tag |
|
|
||||||
|---|---|
|
|
||||||
| mpv video backend + compositing | `UR-080 \| DR-231, IR-033` |
|
|
||||||
| Render-context lifetime binding | `UR-080 \| DR-232` |
|
|
||||||
| Frame pacing | `UR-080 \| DR-233` |
|
|
||||||
| Renderer-dependent device profile | `UR-080, UR-070 \| DR-234` |
|
|
||||||
| Webview video path removed | `UR-080 \| DR-235` |
|
|
||||||
| Hardware-decode policy | `UR-080 \| DR-236` |
|
|
||||||
| Windows surface + cross-build | `UR-080 \| DR-237` |
|
|
||||||
|
|
||||||
## Notes for the implementer
|
|
||||||
|
|
||||||
- **Read the spike before writing a line.** Its three traps and its
|
|
||||||
hardware-decode table are the most valuable things in this directory, and each
|
|
||||||
cost a debugging cycle to find.
|
|
||||||
- **mpv consumes `StreamSelection`; it does not decide.** The transport is on the
|
|
||||||
queue item (DR-230). If you are parsing a URL, stop.
|
|
||||||
- **Guard nothing on `cfg!(target_os = "linux")` that phase 2 will need.** That is
|
|
||||||
the one avoidable mistake here.
|
|
||||||
- The Android backend is the reference for the *shape* of this — transparent
|
|
||||||
webview over a native surface at index 0. Read `05-platform-backends.md`'s
|
|
||||||
Android section for what shipped and what its defects were (DR-184 surface
|
|
||||||
lifetime, DR-194 letterbox).
|
|
||||||
- Do not call sync/blocking APIs from mpv event callbacks that can re-enter the
|
|
||||||
player or hold a lock. The existing deadlock gotchas apply.
|
|
||||||
- A parallel Claude session may be active in this repo — `git diff` before
|
|
||||||
"repairing" unexpected changes.
|
|
||||||
- This branch is stacked on backend-owned stream selection. Rebase when that
|
|
||||||
merges rather than merging master into it.
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
# Spec: Diagnostics and persistent logging
|
|
||||||
|
|
||||||
**Status:** Proposed
|
|
||||||
**Requirements:** UR-078 → DR-218; tests UT-209
|
|
||||||
**UX spec:** n/a (one Settings section; no new flow)
|
|
||||||
**Destination on completion:** [09-security.md](../architecture/09-security.md)
|
|
||||||
for the redaction rules, and a new "Logging and diagnostics" section in
|
|
||||||
[01-rust-backend.md](../architecture/01-rust-backend.md) for the capture path.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
JellyTau records what it does, keeps it in a size-capped file on disk, survives a
|
|
||||||
crash, and can hand the whole thing to the user as one file to attach to a bug
|
|
||||||
report. Credentials never reach that file.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
Today the app forgets everything the moment it exits.
|
|
||||||
|
|
||||||
The Rust half logs through `env_logger` to **stdout only**. A user who launched
|
|
||||||
from a desktop icon has no stdout. On Android it is worse than useless:
|
|
||||||
`env_logger` writes to stdout, which is not logcat, so **the Rust backend's
|
|
||||||
output is invisible on the platform where most of the hard bugs have been** — the
|
|
||||||
autoplay deadlock, the truncated-stream restart, the background-audio stall. The
|
|
||||||
frontend has a proper leveled facade (`logger.ts`, DR-204) but it only reaches
|
|
||||||
the webview console, which nobody can read on a phone.
|
|
||||||
|
|
||||||
The practical consequence is visible in this project's history: several bugs took
|
|
||||||
multiple rounds of "can you reproduce it under `adb logcat`" before anyone could
|
|
||||||
even see what happened. A user reporting "the episode randomly restarted" is
|
|
||||||
reporting the symptom of a race whose evidence was discarded microseconds later.
|
|
||||||
|
|
||||||
There is also no crash record at all. If the app panics, the user sees it vanish
|
|
||||||
and we learn nothing.
|
|
||||||
|
|
||||||
## Layer assignment
|
|
||||||
|
|
||||||
| Logic / responsibility | Layer | Why it belongs there |
|
|
||||||
|---|---|---|
|
|
||||||
| What is captured, at what level, and where it is written | Rust | Retention and capture policy is backend behaviour; it must work identically whether the UI is open, backgrounded, or gone |
|
|
||||||
| Log rotation and the size cap | Rust | Storage management, same class as the download and image caches |
|
|
||||||
| **Redaction of credentials** | Rust | Security-critical, and the values (tokens, `api_key`, keyring payloads) are domain vocabulary owned by the auth layer. A frontend that redacted its own messages would still not cover anything Rust wrote |
|
|
||||||
| Panic capture and persistence | Rust | Only Rust can install a panic hook |
|
|
||||||
| Assembling the export (archive + environment summary) | Rust | Touches the filesystem and the app's own paths; also the last point at which redaction can be enforced over everything |
|
|
||||||
| Which log level is active | Rust owns the *stored* setting and applies it; the frontend renders the picker | Same split as every other setting: the value is state the backend acts on, the control is presentation |
|
|
||||||
| Showing the export path / opening the folder | Frontend | Pure presentation |
|
|
||||||
| Formatting a log line for the webview console | Frontend | `logger.ts` already owns this; unchanged |
|
|
||||||
|
|
||||||
Borderline: **the frontend forwarding its own messages into the Rust sink.**
|
|
||||||
Arguably presentation "sending data down". Placed as: the frontend calls a
|
|
||||||
plugin, and the *decision of what to persist and how to redact it* stays in Rust
|
|
||||||
— which is the tie-breaker, because a bug report containing a token would be a
|
|
||||||
security defect regardless of which half wrote the line.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### Capture
|
|
||||||
|
|
||||||
Replace the `env_logger` init in `lib.rs` with `tauri-plugin-log`, which is the
|
|
||||||
official plugin and already does the three things we would otherwise hand-roll
|
|
||||||
(CLAUDE.md: prefer official plugins before writing native code):
|
|
||||||
|
|
||||||
| Target | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `Stdout` | unchanged behaviour for `bun run tauri dev` |
|
|
||||||
| `LogDir { file_name: "jellytau" }` | the persistent, rotating file |
|
|
||||||
| `Webview` (dev only) | Rust lines visible in the webview console while developing |
|
|
||||||
|
|
||||||
On Android the plugin routes to **logcat**, which is the single largest
|
|
||||||
improvement here and needs no code of ours.
|
|
||||||
|
|
||||||
Rotation: `RotationStrategy::KeepAll` is wrong for a phone. Use a size cap
|
|
||||||
(5 MB) with one retained previous file, so a long session cannot fill a device
|
|
||||||
and yesterday's evidence still exists.
|
|
||||||
|
|
||||||
Level: default `Info`, `RUST_LOG` still honoured, and a stored user preference
|
|
||||||
that survives restart (a user reproducing a bug needs debug logging *across* the
|
|
||||||
restart that reproduces it).
|
|
||||||
|
|
||||||
### Redaction
|
|
||||||
|
|
||||||
A pure function in a new `src-tauri/src/utils/diagnostics.rs`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub fn redact(line: &str) -> String
|
|
||||||
```
|
|
||||||
|
|
||||||
It replaces the value in each of these with `[REDACTED]`, case-insensitively:
|
|
||||||
|
|
||||||
- `api_key=…` and `ApiKey=…` in URLs and query strings
|
|
||||||
- `X-Emby-Token: …`, `X-MediaBrowser-Token: …`, `Authorization: …` headers
|
|
||||||
- `"AccessToken":"…"` in JSON bodies
|
|
||||||
- `MediaBrowser Token="…"` in the Emby auth header form
|
|
||||||
|
|
||||||
What it deliberately does **not** remove: the server host, item ids, and
|
|
||||||
filenames. Those are what make a log useful, they are not secrets, and stripping
|
|
||||||
them would produce a diagnostic bundle nobody can diagnose anything from.
|
|
||||||
|
|
||||||
Applied at two points: on every line the export copies, and — because the export
|
|
||||||
is not the only way a file leaves a device — inside the log formatter itself, so
|
|
||||||
the token never reaches disk in the first place. The export-time pass exists to
|
|
||||||
cover files written before an upgrade.
|
|
||||||
|
|
||||||
### Export
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn diagnostics_export(app: AppHandle) -> Result<DiagnosticsBundle, String>
|
|
||||||
```
|
|
||||||
|
|
||||||
Writes a single `.zip` and returns where it went:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[derive(Serialize, Type)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct DiagnosticsBundle {
|
|
||||||
pub path: String,
|
|
||||||
pub size_bytes: u64,
|
|
||||||
pub file_count: usize,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Contents: the current and previous log files (redacted), plus `environment.txt`
|
|
||||||
— app version, OS and arch, whether the build is debug, the active log level, and
|
|
||||||
the *scheme and host* of the configured server. No token, no username, no path
|
|
||||||
inside the user's home beyond the app's own directories.
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
`logger.ts` keeps its `console.*` pass-through untouched — live object references
|
|
||||||
in devtools are a stated design goal of DR-204 — and *additionally* forwards a
|
|
||||||
stringified copy at `info` and above to the plugin, so one timeline contains both
|
|
||||||
halves of the app. Forwarding is fire-and-forget and never throws into a caller:
|
|
||||||
a logging failure must not become an application failure.
|
|
||||||
|
|
||||||
A `Diagnostics` section in Settings shows the log location, a level picker, and
|
|
||||||
an **Export diagnostics** button that reports the resulting path and, on desktop,
|
|
||||||
offers to reveal it.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- **An Android share sheet.** Export writes to the app's files directory and
|
|
||||||
reports the path; wiring a native `ACTION_SEND` intent is a Kotlin change that
|
|
||||||
belongs with the other native work, not here.
|
|
||||||
- **Uploading anywhere.** Nothing is transmitted. The user attaches the file
|
|
||||||
themselves, which is also what keeps this from becoming telemetry.
|
|
||||||
- **Frontend `debug` forwarding.** Only `info`+ crosses the IPC boundary; per-tick
|
|
||||||
player debug would be thousands of calls a minute.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [ ] Rust logs reach a rotating file on Linux and **logcat** on Android.
|
|
||||||
- [ ] A panic is recorded and is present in the next export.
|
|
||||||
- [ ] Frontend `info`/`warn`/`error` appear in the same file as Rust's lines.
|
|
||||||
- [ ] An export containing a request URL with `api_key=` shows `[REDACTED]`, and
|
|
||||||
a test greps the produced bundle for the token to prove it.
|
|
||||||
- [ ] The log file cannot exceed the cap.
|
|
||||||
- [ ] `bun run check`, `bun run test`, `cargo fmt`, `cargo clippy -D warnings`,
|
|
||||||
`bun run test:rust`, `bun run check:boundary` all pass.
|
|
||||||
- [ ] `bindings.ts` regenerated (new command and struct).
|
|
||||||
- [ ] New code carries `TRACES:` comments.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
**Rust** (`cargo test`): `redact` over each credential shape, including one
|
|
||||||
already-redacted line (idempotent) and a line containing no secret (unchanged);
|
|
||||||
that the environment summary contains a host but no token; that rotation respects
|
|
||||||
the cap.
|
|
||||||
|
|
||||||
**Frontend** (`vitest`): that the forwarder is called for `info`+ and not for
|
|
||||||
`debug`; that a rejected forward does not propagate to the caller.
|
|
||||||
|
|
||||||
## TRACES
|
|
||||||
|
|
||||||
| Piece | Tag |
|
|
||||||
|---|---|
|
|
||||||
| `utils/diagnostics.rs` | `UR-078 \| DR-218` |
|
|
||||||
| `commands/diagnostics.rs` | `UR-078 \| DR-218` |
|
|
||||||
| logging init in `lib.rs` | `UR-078 \| DR-218` |
|
|
||||||
| `logger.ts` forwarding | `UR-078 \| DR-204, DR-218` |
|
|
||||||
| Settings section | `UR-078 \| DR-218` |
|
|
||||||
| tests | `\| DR-218 \| UT-209` |
|
|
||||||
|
|
||||||
## Notes for the implementer
|
|
||||||
|
|
||||||
- A parallel Claude session may be active — `git diff` before "repairing"
|
|
||||||
anything unexpected.
|
|
||||||
- `utils/lock.rs` already sets and restores a panic hook in its tests. The
|
|
||||||
diagnostics hook must chain to the previous hook rather than replace it, or
|
|
||||||
those tests start reporting panics they deliberately suppress.
|
|
||||||
- Do not call the exporter from an event callback that can re-enter the player:
|
|
||||||
it does blocking file I/O (see the deadlock note in CLAUDE.md).
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
# Spec: Remove Jellyfin-specific models from the frontend
|
|
||||||
|
|
||||||
> **Implementation status (branch `frontend-domain-model`, worktree
|
|
||||||
> `../JellyTau-domain-model`):** Catalog surface **done**. The frontend's *item
|
|
||||||
> classification* and *time units* no longer speak Jellyfin:
|
|
||||||
> - `domain/` module is the single source of truth; `MediaKind` enum + isolated
|
|
||||||
> `from_jellyfin` mapping. The model gained real distinctions the flat
|
|
||||||
> `item_type` had hidden: `LiveChannel` / `ChannelItem` / `Channel`.
|
|
||||||
> - Every catalog `item.type === "..."` → `item.kind` (0 remaining in `src/`).
|
|
||||||
> - Catalog ticks → milliseconds (`durationMs`, `playbackPositionMs`);
|
|
||||||
> `formatDuration` takes ms; progress bars are unit-consistent.
|
|
||||||
> - User-facing type badge → `kindLabel()`.
|
|
||||||
> - Old Jellyfin-named fields remain **dual-carried** on the wire so nothing broke.
|
|
||||||
>
|
|
||||||
> **Deferred (tracked, not done):**
|
|
||||||
> - `primaryImageTag` → `imageId` rename (naming-only; ~40 sites across catalog +
|
|
||||||
> `PlayerMediaItem`/`MergedMediaItem`, the latter needing a Rust `image_id`
|
|
||||||
> round-trip). Catalog `MediaItem` already has `imageId`.
|
|
||||||
> - Player/session/reporting tick math (`Queue`, `SessionCard`, `RemoteControls`,
|
|
||||||
> `playbackReporting`, `playerEvents`) — crosses storage/Jellyfin *command
|
|
||||||
> signatures* in ticks; needs those commands to accept ms (phase 4).
|
|
||||||
> - `stream.type` (`mediaStreams[].type`) — Jellyfin stream vocabulary (phase 4).
|
|
||||||
> - Delete `playbackUnits.ts` / `jellyfinFieldMapping.ts` once their last
|
|
||||||
> consumers migrate; drop the dual-carried fields once nothing reads them.
|
|
||||||
|
|
||||||
**Status:** Partially implemented (catalog surface); see banner.
|
|
||||||
**Requirements:** Architectural (boundary integrity — CLAUDE.md core principles).
|
|
||||||
Allocate new DRs on acceptance; suggested: DR for the domain `MediaItem`/`MediaKind`
|
|
||||||
type, DR for tick/image-tag hoisting, DR for the phased frontend migration
|
|
||||||
(see [requirements.md](../requirements.md)). Relates to UR-007, UR-008, UR-034.
|
|
||||||
**UX spec:** n/a — zero user-visible behaviour change. This is a pure
|
|
||||||
architecture/boundary migration.
|
|
||||||
**Supersedes / revises:** none. Extends the boundary work started in
|
|
||||||
[scoped-search-boundary.md](scoped-search-boundary.md) from *taxonomy* to the
|
|
||||||
*whole media model*.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
The frontend currently consumes Jellyfin's data model directly: `MediaItem` is a
|
|
||||||
Jellyfin DTO (`runTimeTicks`, `primaryImageTag`, `parentIndexNumber`, a
|
|
||||||
stringly-typed `type: string` carrying Jellyfin's item vocabulary), mirrored via
|
|
||||||
specta into **36+ frontend files**, with **127 `item.type === "…"` string
|
|
||||||
comparisons across 23 files** and two frontend utility modules
|
|
||||||
(`playbackUnits.ts`, `jellyfinFieldMapping.ts`) doing Jellyfin-specific unit and
|
|
||||||
field conversion in the presentation layer.
|
|
||||||
|
|
||||||
This spec defines a **provider-neutral domain model**, owned by Rust, that the
|
|
||||||
Jellyfin repository maps *into*. The frontend consumes only that model. When done,
|
|
||||||
no Jellyfin vocabulary — item-type strings, ticks, image tags, Jellyfin field
|
|
||||||
names — remains in `src/`.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
Two concrete problems, one strategic:
|
|
||||||
|
|
||||||
1. **Boundary violation at scale.** Per CLAUDE.md, the frontend is
|
|
||||||
presentation-only and Rust owns the domain. Today the *domain model itself* is
|
|
||||||
Jellyfin's wire shape, propagated unchanged across IPC. The frontend knows what
|
|
||||||
a "tick" is, what `primaryImageTag` means, and that `"Audio"` is a track. That
|
|
||||||
is domain knowledge in the wrong layer, 36 files deep.
|
|
||||||
2. **Fragility.** `type: string` is unchecked: a typo (`"Epis0de"`) or a Jellyfin
|
|
||||||
rename fails silently at runtime with no compiler help, across 127 sites. Tick
|
|
||||||
math (`* 10_000_000`) duplicated frontend-side is a class of bug the backend
|
|
||||||
should have already resolved.
|
|
||||||
3. **Strategic (the reason we chose the ambitious target):** a neutral domain
|
|
||||||
model is the precondition for **ever supporting a non-Jellyfin backend** (Plex,
|
|
||||||
local files, Subsonic). As long as the UI speaks Jellyfin, that door is welded
|
|
||||||
shut.
|
|
||||||
|
|
||||||
## Layer assignment
|
|
||||||
|
|
||||||
| Logic / responsibility | Layer | Why it belongs there |
|
|
||||||
|------------------------|-------|----------------------|
|
|
||||||
| Definition of the media domain model (`MediaItem`, `MediaKind`) | **Rust** | The canonical shape the whole app reasons about; must not be a provider's wire format. |
|
|
||||||
| Jellyfin DTO → domain mapping (ticks→ms, image tag→url/id, `"Audio"`→`Track`, `PremiereDate`→`releaseDate`) | **Rust**, in the Jellyfin repository | Provider-specific translation; changes if Jellyfin changes; is the definition of "how Jellyfin maps to our domain." |
|
|
||||||
| Tick arithmetic (`playbackUnits.ts`) | **Rust** | A Jellyfin unit. The frontend should never see ticks; it receives `durationMs`/`positionMs`. |
|
|
||||||
| Sort-field mapping (`jellyfinFieldMapping.ts`, `title→SortName`) | **Rust** | Maps neutral sort keys to Jellyfin query fields — provider vocabulary. Frontend sends a neutral `SortKey`. |
|
|
||||||
| `MediaKind` classification (is this a track / album / episode?) | **Rust** | Derived from Jellyfin's `item_type`; the frontend receives the already-classified kind. |
|
|
||||||
| Choosing which kind renders as a card vs a list row; grid/list toggle; group order | **Frontend** | Pure presentation over the neutral `kind`. Changes only if the UI is redesigned. |
|
|
||||||
| Navigation decisions (`kind === Track && albumId` → go to album) | **Frontend** | Presentation/routing over neutral fields. |
|
|
||||||
|
|
||||||
**Borderline calls, resolved:**
|
|
||||||
|
|
||||||
- *`MergedMediaItem`* (the lightweight now-playing projection) is already
|
|
||||||
half-neutral (`title`, `artist`, `duration`) — it becomes a straightforward
|
|
||||||
subset of the new domain model, not a special case.
|
|
||||||
- *Context discriminators* `"album"`, `"playlist"`, `"remote"` (in `TrackList`,
|
|
||||||
playback context, sessions) are **already domain-neutral** — they are *our*
|
|
||||||
vocabulary, not Jellyfin's. They stay as-is; do not confuse them with
|
|
||||||
`item_type`. Only the Jellyfin item-type strings move.
|
|
||||||
- *`mediaStreams[].type === "Audio"/"Subtitle"/"Video"`* (track selection in
|
|
||||||
VideoPlayer) is Jellyfin stream vocabulary too, but is lower-risk and
|
|
||||||
self-contained — deferred to a late phase, not phase 1.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### Single canonical model, one location, isolated mappings
|
|
||||||
|
|
||||||
The domain model is defined **once**, in a dedicated top-level Rust module
|
|
||||||
`src-tauri/src/domain/`, and is the single source of truth shared across the
|
|
||||||
whole app:
|
|
||||||
|
|
||||||
```
|
|
||||||
src-tauri/src/domain/
|
|
||||||
media.rs canonical MediaItem, MediaKind, and the other media types
|
|
||||||
from_jellyfin.rs Jellyfin DTO -> domain mapping, ISOLATED here
|
|
||||||
mod.rs re-exports
|
|
||||||
| tauri-specta (export_typescript_bindings test)
|
|
||||||
v
|
|
||||||
src/lib/api/bindings.ts generated MediaItem/MediaKind — the frontend copy
|
|
||||||
```
|
|
||||||
|
|
||||||
- **One definition.** `domain::MediaItem` is *the* model. Rust (repositories,
|
|
||||||
player, downloads) uses it directly. The frontend uses the generated `bindings.ts`
|
|
||||||
projection of it. There is no second hand-written copy in either language, so it
|
|
||||||
cannot drift — "shared between frontend and backend" is realized by generation,
|
|
||||||
not duplication.
|
|
||||||
- **Mappings live beside the model, never in consumers.** All provider translation
|
|
||||||
(`JellyfinItem` → `domain::MediaItem`, ticks→ms, image-tag→id, item-type→`MediaKind`)
|
|
||||||
lives in `domain/from_jellyfin.rs`. It is the *only* place Jellyfin vocabulary
|
|
||||||
touches the domain type. Adding a second provider later means a new
|
|
||||||
`from_<provider>.rs` beside it — the model and every consumer stay untouched.
|
|
||||||
- **`domain` is a top-level module** (not under `repository/`) because `MediaItem`
|
|
||||||
is used by `player/`, `download/`, and `playback_mode/` too — it is not
|
|
||||||
repository-specific.
|
|
||||||
- The existing `JellyfinItem` DTO + `to_media_item()` in
|
|
||||||
[online.rs](../../src-tauri/src/repository/online.rs) is the seam that already
|
|
||||||
exists; it **moves** into `domain/from_jellyfin.rs` and is enriched to do real
|
|
||||||
translation instead of copying `item_type` through.
|
|
||||||
|
|
||||||
### The domain model (Rust)
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// src-tauri/src/domain/media.rs — provider-neutral. NO Jellyfin vocabulary.
|
|
||||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub enum MediaKind {
|
|
||||||
Track, Album, Artist, Playlist, // music
|
|
||||||
Movie, Series, Season, Episode, // video
|
|
||||||
Person, // cast/crew
|
|
||||||
Channel, Folder, // containers/live
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct MediaItem {
|
|
||||||
pub id: String,
|
|
||||||
pub name: String,
|
|
||||||
pub kind: MediaKind, // was: type: String
|
|
||||||
pub is_folder: bool,
|
|
||||||
pub server_id: String,
|
|
||||||
|
|
||||||
// Times in milliseconds — NEVER ticks.
|
|
||||||
pub duration_ms: Option<i64>, // was: run_time_ticks
|
|
||||||
|
|
||||||
// Image as a resolved identifier the frontend turns into a URL via the
|
|
||||||
// existing image command — no raw Jellyfin tag semantics leak.
|
|
||||||
pub image_id: Option<String>, // was: primary_image_tag
|
|
||||||
pub backdrop_image_ids: Option<Vec<String>>,
|
|
||||||
|
|
||||||
pub overview: Option<String>,
|
|
||||||
pub genres: Option<Vec<String>>,
|
|
||||||
pub production_year: Option<i32>,
|
|
||||||
pub release_date: Option<String>, // was: premiere_date (ISO-8601)
|
|
||||||
pub community_rating: Option<f64>,
|
|
||||||
pub official_rating: Option<String>,
|
|
||||||
|
|
||||||
// Relationships — already neutral, kept.
|
|
||||||
pub album_id: Option<String>, pub album_name: Option<String>,
|
|
||||||
pub album_artist: Option<String>, pub artists: Option<Vec<String>>,
|
|
||||||
pub artist_items: Option<Vec<ArtistItem>>,
|
|
||||||
pub series_id: Option<String>, pub series_name: Option<String>,
|
|
||||||
pub season_id: Option<String>, pub season_name: Option<String>,
|
|
||||||
|
|
||||||
// Ordinal position — rename off Jellyfin's index vocabulary.
|
|
||||||
pub track_number: Option<i32>, // was: index_number
|
|
||||||
pub disc_number: Option<i32>, // was: parent_index_number
|
|
||||||
|
|
||||||
pub user_data: Option<UserData>,
|
|
||||||
pub media_streams: Option<Vec<MediaStream>>,
|
|
||||||
pub media_sources: Option<Vec<MediaSource>>,
|
|
||||||
pub people: Option<Vec<Person>>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The existing `JellyfinItem` DTO (already defined in `online.rs`, deserialized
|
|
||||||
from the Jellyfin JSON) **moves into `domain/from_jellyfin.rs`** and stays
|
|
||||||
private to that module. Its `to_media_item()` — today a near-passthrough that
|
|
||||||
copies `item_type` straight across — is enriched into the single, tested place
|
|
||||||
that:
|
|
||||||
|
|
||||||
- classifies `item_type: String` → `MediaKind` (including the edge cases found in
|
|
||||||
the audit: `"ChannelFolderItem"` → `Channel`/`Folder` by `is_folder`,
|
|
||||||
`"TvChannel"` → `Channel`, `"Composer"/"Director"/"Writer"` → `Person`,
|
|
||||||
`"Video"` → `Movie` or a video leaf). Unknown strings map to `Folder` or a new
|
|
||||||
`Other` variant — **decide at implementation; must not panic.**
|
|
||||||
- converts `run_time_ticks` → `duration_ms` (`ticks / 10_000`).
|
|
||||||
- maps `PremiereDate` → `release_date`, image tags → image ids.
|
|
||||||
|
|
||||||
`SortKey` enum + its Jellyfin field mapping (`jellyfinFieldMapping.ts` contents)
|
|
||||||
moves into the Jellyfin repository; the command takes a neutral `SortKey`.
|
|
||||||
|
|
||||||
### 🔴 The `search-event` / dual-payload rule applies again
|
|
||||||
|
|
||||||
Every path that returns `MediaItem` — command returns **and** the `search-event`
|
|
||||||
and any other event payloads — emits the new domain shape. Both sides of a
|
|
||||||
twice-delivered result must match (same rule as
|
|
||||||
[scoped-search-boundary.md](scoped-search-boundary.md)). Grep for `MediaItem` in
|
|
||||||
event definitions before declaring a phase done.
|
|
||||||
|
|
||||||
### Frontend after
|
|
||||||
|
|
||||||
- `MediaItem`/`MediaKind` come from generated `bindings.ts`.
|
|
||||||
- `item.type === "Audio"` → `item.kind === "track"` (127 sites, mechanical).
|
|
||||||
- `runTimeTicks` usages → `durationMs`; **delete `playbackUnits.ts`** (ticks no
|
|
||||||
longer cross the boundary; keep only any purely-display seconds↔clock helpers if
|
|
||||||
they exist, which are not Jellyfin-specific).
|
|
||||||
- `primaryImageTag` → `imageId` through the existing image-URL command.
|
|
||||||
- **Delete `jellyfinFieldMapping.ts`**; sort options send a neutral `SortKey`.
|
|
||||||
- Assert with the boundary tripwire + a new grep (see acceptance).
|
|
||||||
|
|
||||||
## Phased migration
|
|
||||||
|
|
||||||
This is too large and too collision-prone for one change. Phases are independently
|
|
||||||
shippable, each keeps all tests green, and each is a reviewable PR:
|
|
||||||
|
|
||||||
1. **Establish the `domain/` module + enriched mapping, tests — no frontend
|
|
||||||
change yet.** Create `src-tauri/src/domain/{media,from_jellyfin,mod}.rs`. Move
|
|
||||||
`JellyfinItem`/`to_media_item` in. Add `MediaKind` and the neutral fields to
|
|
||||||
`domain::MediaItem` as *additive, defaulted* fields, and populate them in the
|
|
||||||
mapping, while **keeping the old Jellyfin-named fields too** (dual-carry). The
|
|
||||||
wire shape is a superset of today's, so the frontend still compiles and
|
|
||||||
behaves identically. Lands the authority + full mapping unit coverage first,
|
|
||||||
with zero blast radius on the 52 construction sites (they set the old fields;
|
|
||||||
new ones default).
|
|
||||||
2. **Flip the wire shape.** Commands + events emit the new `MediaItem`.
|
|
||||||
Regenerate `bindings.ts`. Frontend breaks to compile errors — fix them
|
|
||||||
mechanically (`type`→`kind`, values `"Audio"`→`"track"`, `runTimeTicks`→
|
|
||||||
`durationMs`, `primaryImageTag`→`imageId`). This is the big mechanical PR;
|
|
||||||
`bun run check` is the driver.
|
|
||||||
3. **Delete the frontend conversion helpers** (`playbackUnits.ts` ticks,
|
|
||||||
`jellyfinFieldMapping.ts`) and route sorting through the neutral `SortKey`.
|
|
||||||
4. **Stream vocabulary** (`mediaStreams[].type`) and any remaining stragglers;
|
|
||||||
tighten the boundary check to forbid Jellyfin item-type strings in `src/`
|
|
||||||
outside tests.
|
|
||||||
|
|
||||||
Ship 1 → 2 → 3 → 4 as separate PRs. Do **not** attempt all four at once.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- Actually adding a second backend (Plex/Subsonic). This spec only *unblocks* it.
|
|
||||||
- Changing any user-visible behaviour, layout, or copy.
|
|
||||||
- The player-internal `PlayerMediaItem` / `MediaSessionType` shapes, except where
|
|
||||||
they carry the fields being renamed — align them in phase 2 only if the compiler
|
|
||||||
demands it.
|
|
||||||
- Context discriminators (`"album"`, `"playlist"`, `"remote"`) — already neutral.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [ ] No Jellyfin item-type string (`"Audio"`, `"MusicAlbum"`, `"Series"`, …) is
|
|
||||||
compared against `.type`/`.kind` anywhere in `src/` (outside tests). Verify:
|
|
||||||
`grep -rIn '\.kind === "\(Audio\|MusicAlbum\|MusicArtist\|Series\|Episode\|Movie\|Playlist\)"' src/` returns nothing.
|
|
||||||
- [ ] No `Ticks`, `runTimeTicks`, `primaryImageTag`, `PremiereDate`, or Jellyfin
|
|
||||||
sort-field name (`SortName`, `RunTimeTicks`, …) appears in `src/` outside
|
|
||||||
tests. `playbackUnits.ts` (ticks) and `jellyfinFieldMapping.ts` are deleted.
|
|
||||||
- [ ] `MediaItem`/`MediaKind`/`SortKey` in the frontend come from `bindings.ts`.
|
|
||||||
- [ ] The `From<JellyfinMediaDto>` mapping is total and never panics on an unknown
|
|
||||||
item type (Rust test with a garbage type string).
|
|
||||||
- [ ] Behaviour is identical: same library/search/home rendering, same sorting,
|
|
||||||
same navigation, offline included.
|
|
||||||
- [ ] Both command returns and event payloads carry the new shape (no flicker).
|
|
||||||
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass;
|
|
||||||
`cargo fmt`/`cargo clippy`/`bun run test:rust` pass; `bindings.ts` regenerated.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
**Rust** (`cargo test`): the `From<JellyfinMediaDto> for MediaItem` mapping is the
|
|
||||||
critical surface —
|
|
||||||
- every known `item_type` → correct `MediaKind` (table test over all 20 values
|
|
||||||
found in the audit, incl. `ChannelFolderItem`, `TvChannel`, `Composer`);
|
|
||||||
- unknown type string → safe fallback, no panic;
|
|
||||||
- `run_time_ticks` → `duration_ms` (10_000 divisor), boundary/None cases;
|
|
||||||
- `SortKey` → Jellyfin field mapping (port `jellyfinFieldMapping.ts`'s cases).
|
|
||||||
|
|
||||||
**Frontend** (vitest): update the many tests asserting `.type`/`runTimeTicks`;
|
|
||||||
they become `.kind`/`durationMs`. `jellyfinFieldMapping`/`playbackUnits` tests are
|
|
||||||
deleted with their modules. Add a compose/render test proving `kind`-based
|
|
||||||
branching matches the old `type`-based branching for a representative mix.
|
|
||||||
|
|
||||||
## TRACES
|
|
||||||
|
|
||||||
Per [CLAUDE.md](../../CLAUDE.md): the domain type + mapping
|
|
||||||
`UR-007, UR-008 | <new DR>`; the tick/field hoist `<new DR>`; frontend migration
|
|
||||||
phases share the DRs of the capability each touches (don't invent per-file DRs).
|
|
||||||
|
|
||||||
## Notes for the implementer
|
|
||||||
|
|
||||||
- **This is the highest-collision change in the repo's history** — it touches 36+
|
|
||||||
frontend files and the core Rust types. A parallel Claude session in any media
|
|
||||||
file will conflict. Strongly prefer a dedicated worktree per phase, and
|
|
||||||
`git diff` before repairing anything (CLAUDE.md gotchas / project memory).
|
|
||||||
- Phase 1 deliberately maps *back* to the old shape so it can land safely ahead of
|
|
||||||
the disruptive flip. Resist the urge to skip it.
|
|
||||||
- IPC camelCase rules apply to the new enums/structs
|
|
||||||
([04-type-sync-and-threading.md](../architecture/04-type-sync-and-threading.md)):
|
|
||||||
`#[serde(rename_all = "camelCase")]`; tagged-enum tag convention; regenerate
|
|
||||||
`bindings.ts`, never hand-edit.
|
|
||||||
- Reviewed against [SPEC-REVIEW-CHECKLIST.md](SPEC-REVIEW-CHECKLIST.md) — the
|
|
||||||
Layer assignment table above is the load-bearing section.
|
|
||||||
@@ -1,732 +0,0 @@
|
|||||||
<!--
|
|
||||||
Companion to jellyfin-server-version-compatibility.md — the evidence base for
|
|
||||||
every decision in it. Kept in the repo because the *reasoning* is what a future
|
|
||||||
change needs: which differences were verified, which were looked for and could
|
|
||||||
NOT be established, and which URL each claim came from.
|
|
||||||
|
|
||||||
Delete this alongside the spec when the last of it ships and the design is
|
|
||||||
folded into docs/architecture/.
|
|
||||||
-->
|
|
||||||
|
|
||||||
# Jellyfin server API delta: 10.11.x → next major
|
|
||||||
|
|
||||||
Research date: **2026-09-08**. All claims verified against live sources; no claim below is
|
|
||||||
from model memory. Method: GitHub Releases/Tags API, the official release blog, the published
|
|
||||||
OpenAPI spec, and a **byte-level diff of the actual C# source trees** at tags `v10.11.5` and
|
|
||||||
`v12.0` (downloaded from `codeload.github.com`, extracted locally).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Section 1 — Version reality check
|
|
||||||
|
|
||||||
### 🔴 Jellyfin 11.0 does not exist and never did.
|
|
||||||
|
|
||||||
The complete tag list of `jellyfin/jellyfin` contains **zero** `v11.*` tags. The project went
|
|
||||||
directly from the `10.11.x` branch to `12.0`.
|
|
||||||
|
|
||||||
Source: `https://api.github.com/repos/jellyfin/jellyfin/tags` (all 8 pages; 116 tags total).
|
|
||||||
Major-version histogram: `v10` × 107, `v12` × 8, `v3` × 1. `11.x tags: []`.
|
|
||||||
|
|
||||||
### What actually exists today (2026-09-08)
|
|
||||||
|
|
||||||
| Version | Status | Published | Source |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **12.0** | **Current stable / `releases/latest`** | **2026-09-08T01:38:39Z** (today) | `https://api.github.com/repos/jellyfin/jellyfin/releases/latest` |
|
|
||||||
| 12.0-rc1 … rc7 | prereleases | 2026-06 → 2026-08-31 | `https://api.github.com/repos/jellyfin/jellyfin/releases` |
|
|
||||||
| 10.11.11 | last release on the 10.11 branch | 2026-06-06T16:18:54Z | `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v10.11.11` |
|
|
||||||
| 10.11.5 | **what JellyTau targets** | 2025-12-15 (file mtime in tag tarball) | `https://codeload.github.com/jellyfin/jellyfin/tar.gz/refs/tags/v10.11.5` |
|
|
||||||
| 10.11.0 | 10.11 branch opened | 2025-10-20 | `https://jellyfin.org/posts/jellyfin-release-10.11.0` |
|
|
||||||
|
|
||||||
**v12.0 was released roughly 18 hours before this research was performed.** Treat "12.0 in the
|
|
||||||
wild" as approximately zero installs today, rising over the coming months.
|
|
||||||
|
|
||||||
### Why the number jumped 10.11 → 12.0
|
|
||||||
|
|
||||||
Official rationale, quoted from the release blog:
|
|
||||||
|
|
||||||
> "The most visible change in this release is the one in its name: we are dropping the major
|
|
||||||
> version '10' from our naming scheme. What would have been 10.12.0 is simply 12.0, and the
|
|
||||||
> server reports its version as `12.0.0`. 10.11.x was the last release branch to use the old
|
|
||||||
> scheme. […] Jumping to 11.0 would still look like a minor increment […]"
|
|
||||||
|
|
||||||
> "**If you maintain anything that parses Jellyfin version strings** — a client, a monitoring
|
|
||||||
> check, a deployment script, a container tag pin — **this is the item to look at before
|
|
||||||
> upgrading.**"
|
|
||||||
|
|
||||||
Source: `https://jellyfin.org/posts/jellyfin-release-12.0` (dated September 7, 2026)
|
|
||||||
|
|
||||||
So: `12.0` *is* `10.12` under the old scheme. It is one release-branch step from 10.11, not two.
|
|
||||||
**"Two server generations from one build" means 10.11.x and 12.x.** There is no third thing.
|
|
||||||
|
|
||||||
⚠️ Direct consequence for JellyTau: `/System/Info/Public` returns `Version: "12.0.0"` on the new
|
|
||||||
generation and `"10.11.5"` on the old. Any version comparison must not assume a leading `10.`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Section 2 — Confirmed changes
|
|
||||||
|
|
||||||
### 2.1 Routes: the legacy user-scoped family SURVIVES intact
|
|
||||||
|
|
||||||
**The `/Users/{userId}/…` route family that JellyTau depends on in ~17 call sites is NOT removed
|
|
||||||
in 12.0.** Every route the task listed still exists and still functions.
|
|
||||||
|
|
||||||
Verified by diffing every `[HttpGet|Post|Delete|Put|Patch|Head]` attribute across
|
|
||||||
`Jellyfin.Api/Controllers/` in both tags (369 routes in 10.11.5, 364 in 12.0).
|
|
||||||
|
|
||||||
**Complete list of routes removed in 12.0 — all six:**
|
|
||||||
|
|
||||||
| Route | Handler |
|
|
||||||
|---|---|
|
|
||||||
| `POST /Users/{userId}/EasyPassword` | `UpdateUserEasyPassword` |
|
|
||||||
| `GET /Items/{itemId}/CriticReviews` | `GetCriticReviews` |
|
|
||||||
| `GET /Environment/NetworkShares` | `GetNetworkShares` |
|
|
||||||
| `POST /System/MediaEncoder/Path` | `UpdateMediaEncoderPath` |
|
|
||||||
| `GET /LiveTv/Recordings/Groups/{groupId}` | `GetRecordingGroup` |
|
|
||||||
| `GET /QuickConnect/Initiate` | `InitiateQuickConnectLegacy` |
|
|
||||||
|
|
||||||
**Complete list of routes added in 12.0 — one:** `GET /Items/{itemId}/Collections`
|
|
||||||
(`GetItemCollections`).
|
|
||||||
|
|
||||||
Sources:
|
|
||||||
- Route diff computed from `https://codeload.github.com/jellyfin/jellyfin/tar.gz/refs/tags/v10.11.5`
|
|
||||||
and `.../v12.0`, directory `Jellyfin.Api/Controllers/`.
|
|
||||||
- Corroborated verbatim by the release notes: "Removed obsolete API routes: `POST
|
|
||||||
/Users/{userId}/EasyPassword` (the EasyPassword feature is gone), `GET
|
|
||||||
/Items/{itemId}/CriticReviews`, `GET /Environment/NetworkShares`, `POST
|
|
||||||
/System/MediaEncoder/Path`, `GET /LiveTv/Recordings/Groups/{groupId}`, and `GET
|
|
||||||
/QuickConnect/Initiate`" — `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v12.0`
|
|
||||||
|
|
||||||
**Confirmed present and functional in v12.0** (`Jellyfin.Api/Controllers/`, tag `v12.0`):
|
|
||||||
|
|
||||||
| Route | File:line in v12.0 |
|
|
||||||
|---|---|
|
|
||||||
| `GET /Users/{userId}/Items` | `ItemsController.cs:721` |
|
|
||||||
| `GET /Users/{userId}/Items/Resume` | `ItemsController.cs:1027` |
|
|
||||||
| `GET /Users/{userId}/Items/Latest` | `UserLibraryController.cs:619` |
|
|
||||||
| `GET /Users/{userId}/Views` | `UserViewsController.cs:107` |
|
|
||||||
| `GET /Users/{userId}/Items/{itemId}` | `UserLibraryController.cs:117` |
|
|
||||||
| `POST /Users/{userId}/FavoriteItems/{itemId}` | `UserLibraryController.cs:252` |
|
|
||||||
| `DELETE /Users/{userId}/FavoriteItems/{itemId}` | `UserLibraryController.cs:300` |
|
|
||||||
| `POST /Users/{userId}/PlayedItems/{itemId}` | `PlaystateController.cs:120` |
|
|
||||||
| `DELETE /Users/{userId}/PlayedItems/{itemId}` | `PlaystateController.cs:185` |
|
|
||||||
|
|
||||||
### 2.2 …but the whole family was ALREADY deprecated in 10.11.5, and 12.0 hardens the policy
|
|
||||||
|
|
||||||
This is **not a new deprecation**. Every one of those methods already carried
|
|
||||||
`[Obsolete("Kept for backwards compatibility")]` **and** `[ApiExplorerSettings(IgnoreApi = true)]`
|
|
||||||
in 10.11.5, at the same positions. Nothing changed about their status between the two versions.
|
|
||||||
|
|
||||||
Confirmed: the 12.0 OpenAPI spec contains only these `/Users` paths — `/Users`,
|
|
||||||
`/Users/AuthenticateByName`, `/Users/AuthenticateWithQuickConnect`, `/Users/Configuration`,
|
|
||||||
`/Users/ForgotPassword`, `/Users/ForgotPassword/Pin`, `/Users/Me`, `/Users/New`,
|
|
||||||
`/Users/Password`, `/Users/Public`, `/Users/{userId}`, `/Users/{userId}/Policy`.
|
|
||||||
**None of the item/view/favorite/played routes appear.**
|
|
||||||
|
|
||||||
Source: `https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json`
|
|
||||||
(`info.version` = `"12.0.0"`, `x-jellyfin-version` = `"12.0.0"`, 294 paths).
|
|
||||||
|
|
||||||
What *is* new in 12.0 is the written removal policy:
|
|
||||||
|
|
||||||
> "If an endpoint isn't listed in the OpenAPI specification it should not be used by clients.
|
|
||||||
> There are certain endpoints that are still exposed for legacy reasons despite being excluded
|
|
||||||
> from the OpenAPI spec. **These can be removed in any major release without warning.**"
|
|
||||||
> "As a general rule, any deprecations will be marked as such for an entire (major) release cycle
|
|
||||||
> before the deprecated endpoint or parameter is liable for removal."
|
|
||||||
|
|
||||||
Source: `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v12.0`
|
|
||||||
|
|
||||||
**Assessment:** the user-scoped family needs no migration to run on 12.0, but it is now formally
|
|
||||||
removable without notice in 13.0. The replacements (`/Items?userId=`, `/UserViews?userId=`,
|
|
||||||
`/UserFavoriteItems/{itemId}`, `/UserPlayedItems/{itemId}`) **already exist in 10.11.5**, so
|
|
||||||
migrating is a one-generation-compatible change, not a branch.
|
|
||||||
|
|
||||||
Verified: `GET /Items` accepts `[FromQuery] Guid? userId` in v12.0
|
|
||||||
(`ItemsController.cs:171-174`), and non-user-scoped twins exist in *both* trees
|
|
||||||
(`UserLibraryController.cs`: `UserFavoriteItems/{itemId}`, `UserItems/{itemId}/Rating`).
|
|
||||||
|
|
||||||
### 2.3 🔴 AUTHENTICATION — the one genuinely breaking change for JellyTau
|
|
||||||
|
|
||||||
**`X-Emby-Authorization` is disabled by default in 12.0, including on upgraded servers.
|
|
||||||
`api_key` as a query parameter is disabled by default in 12.0.**
|
|
||||||
|
|
||||||
The authoritative accepted/deprecated table, from the Jellyfin core team's canonical
|
|
||||||
client-developer gist (last updated 2026-09-08):
|
|
||||||
|
|
||||||
| Type | Name | Method | Deprecated |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Header | `Authorization` | Schema | **No** |
|
|
||||||
| Query | `ApiKey` | Token only | **No**, but discouraged |
|
|
||||||
| Query | `api_key` | Token only | **yes** |
|
|
||||||
| Header | `X-Emby-Token` | Token only | **yes** |
|
|
||||||
| Header | `X-MediaBrowser-Token` | Token only | **yes** |
|
|
||||||
| Header | `X-Emby-Authorization` | Schema | **yes** |
|
|
||||||
|
|
||||||
Source: `https://gist.github.com/nielsvanvelzen/ea047d9028f676185832e51ffaf12a6f`
|
|
||||||
(referenced from PR #13306 and from the 12.0 release notes)
|
|
||||||
|
|
||||||
**Verified in source.** `Jellyfin.Server.Implementations/Security/AuthorizationContext.cs` is
|
|
||||||
**byte-identical between v10.11.5 and v12.0** except one whitespace change
|
|
||||||
(`authorizationHeader[start.. i]` → `[start..i]`). The gating logic in **both** versions:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// always read, no gate:
|
|
||||||
var auth = httpReq.Headers[HeaderNames.Authorization];
|
|
||||||
if (_configurationManager.Configuration.EnableLegacyAuthorization && string.IsNullOrEmpty(auth))
|
|
||||||
{
|
|
||||||
auth = httpReq.Headers["X-Emby-Authorization"];
|
|
||||||
}
|
|
||||||
...
|
|
||||||
var validName = name.Equals("MediaBrowser", StringComparison.OrdinalIgnoreCase); // always OK
|
|
||||||
validName = validName || (…EnableLegacyAuthorization && name.Equals("Emby", …)); // gated
|
|
||||||
...
|
|
||||||
if (…EnableLegacyAuthorization && string.IsNullOrEmpty(token)) { token = headers["X-Emby-Token"]; }
|
|
||||||
if (…EnableLegacyAuthorization && string.IsNullOrEmpty(token)) { token = headers["X-MediaBrowser-Token"]; }
|
|
||||||
if (string.IsNullOrEmpty(token)) { token = queryString["ApiKey"]; } // NOT gated
|
|
||||||
if (…EnableLegacyAuthorization && string.IsNullOrEmpty(token)) { token = queryString["api_key"]; } // gated
|
|
||||||
```
|
|
||||||
|
|
||||||
Source: `https://raw.githubusercontent.com/jellyfin/jellyfin/v12.0/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs`
|
|
||||||
(and the `v10.11.5` path of the same file)
|
|
||||||
|
|
||||||
**The only difference between the two versions is the default of the gate:**
|
|
||||||
|
|
||||||
- `v10.11.5` — `MediaBrowser.Model/Configuration/ServerConfiguration.cs:290`:
|
|
||||||
`public bool EnableLegacyAuthorization { get; set; } = true;`
|
|
||||||
- `v12.0` — same file, same line: `public bool EnableLegacyAuthorization { get; set; }`
|
|
||||||
(no initializer → C# default `false`)
|
|
||||||
|
|
||||||
Source: `https://raw.githubusercontent.com/jellyfin/jellyfin/v10.11.5/MediaBrowser.Model/Configuration/ServerConfiguration.cs`
|
|
||||||
and `.../v12.0/...`
|
|
||||||
|
|
||||||
**Existing installs are flipped too**, by a migration that runs on first boot:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[JellyfinMigration("2026-05-31T16:00:00", nameof(DisableLegacyAuthorization), …)]
|
|
||||||
public class DisableLegacyAuthorization : IAsyncMigrationRoutine
|
|
||||||
{
|
|
||||||
public Task PerformAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
_serverConfigurationManager.Configuration.EnableLegacyAuthorization = false;
|
|
||||||
_serverConfigurationManager.SaveConfiguration();
|
|
||||||
```
|
|
||||||
|
|
||||||
Source: `tree/jellyfin-12.0/Jellyfin.Server/Migrations/Routines/20260531160000_DisableLegacyAuthorization.cs`
|
|
||||||
(from `https://codeload.github.com/jellyfin/jellyfin/tar.gz/refs/tags/v12.0`)
|
|
||||||
|
|
||||||
Release-note wording: "Legacy authorization is now disabled by default, and a migration disables
|
|
||||||
it on existing installs as well."
|
|
||||||
Source: `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v12.0`
|
|
||||||
|
|
||||||
Blog wording: "the deprecated way of signing in is now disabled, including on existing servers."
|
|
||||||
Source: `https://jellyfin.org/posts/jellyfin-release-12.0`
|
|
||||||
|
|
||||||
Change history (all merged):
|
|
||||||
- PR #13306 "Add option to disable deprecated legacy authorization options", merged
|
|
||||||
2025-01-11, shipped in 10.11 with default `true`. Body: *"The only method we'll allow is the
|
|
||||||
`Authorization` header with `MediaBrowser` scheme and the `ApiKey` query parameter. The other
|
|
||||||
headers (`X-Emby-Authorization`, `X-Emby-Token`, `X-MediaBrowser-Token`), query parameter
|
|
||||||
(`api_key`) and authorization scheme (`Emby`) are all deprecated."*
|
|
||||||
`https://api.github.com/repos/jellyfin/jellyfin/pulls/13306`
|
|
||||||
- PR #15559 "Disable legacy authorization methods by default", merged 2025-11-27. Body:
|
|
||||||
*"We'll remove this configuration option (and the authorization methods) in a future release,
|
|
||||||
likely 10.13."* `https://api.github.com/repos/jellyfin/jellyfin/pulls/15559`
|
|
||||||
- PR #16754 "Keep legacy authorization enabled" (temporary revert), merged 2026-05-05.
|
|
||||||
`https://api.github.com/repos/jellyfin/jellyfin/pulls/16754`
|
|
||||||
- PR #16992 "Re-disable legacy authorization methods by default", merged 2026-06-01 — the
|
|
||||||
state that shipped. `https://api.github.com/repos/jellyfin/jellyfin/pulls/16992`
|
|
||||||
|
|
||||||
#### 🟢 The critical good news: query-param auth for media players is SAFE
|
|
||||||
|
|
||||||
`ApiKey` (capital A, capital K, no underscore) as a **query parameter** is **not** deprecated and
|
|
||||||
**not** gated in either version. The server itself generates it — identically in both trees:
|
|
||||||
|
|
||||||
- `v10.11.5` `MediaBrowser.Model/Dlna/StreamInfo.cs:1042` → `sb.Append("&ApiKey=");`
|
|
||||||
- `v12.0` `MediaBrowser.Model/Dlna/StreamInfo.cs:1034` → `sb.Append("&ApiKey=");`
|
|
||||||
- `v12.0` `StreamInfo.cs:1279-1280` → `// Use "?ApiKey=" as seen in HEAD and other parts of the code`
|
|
||||||
|
|
||||||
So the load-bearing requirement — handing stream URLs to mpv / ExoPlayer / HTML5 `<video>`, which
|
|
||||||
cannot set headers — **remains satisfied on both generations by one code path**, provided the
|
|
||||||
parameter is spelled `ApiKey` rather than `api_key`.
|
|
||||||
|
|
||||||
#### 🔴 JellyTau uses the disabled spellings today
|
|
||||||
|
|
||||||
Grep of `/home/dtourolle/Development/JellyTau/src-tauri/src`:
|
|
||||||
|
|
||||||
- **21 occurrences of `.header("X-Emby-Authorization", …)`** across
|
|
||||||
`auth/mod.rs` (3), `jellyfin/client.rs` (5), `repository/online.rs` (13).
|
|
||||||
- **28 non-test occurrences of `api_key`**, including every stream URL:
|
|
||||||
`repository/online.rs:1046, 2314` (`/Videos/{}/stream?…&api_key={}`),
|
|
||||||
`online.rs:2338` (`/Audio/{}/stream?…&api_key={}`),
|
|
||||||
`online.rs:2464` (`/Videos/{}/master.m3u8?api_key={}&…`),
|
|
||||||
`online.rs:633, 727, 2626`, plus `player/stream_end.rs`, `player/mod.rs`,
|
|
||||||
`jellyfin/http_client.rs`, `repository/device_profile.rs`, `utils/diagnostics.rs`.
|
|
||||||
|
|
||||||
The header **value** JellyTau already builds is correct — `jellyfin/client.rs:60` emits
|
|
||||||
`MediaBrowser Client="…", Version="…", Device="…", DeviceId="…", Token="…"`, which is exactly the
|
|
||||||
`MediaBrowser` scheme the non-deprecated `Authorization` header expects.
|
|
||||||
|
|
||||||
**Therefore the fix is a rename, not a branch:**
|
|
||||||
- `X-Emby-Authorization` → `Authorization` (value unchanged)
|
|
||||||
- `api_key=` → `ApiKey=`
|
|
||||||
|
|
||||||
Both work on 10.11.5 **and** 12.0. **No capability flag is needed for authentication.**
|
|
||||||
|
|
||||||
### 2.4 `POST /Users/AuthenticateByName` — unchanged
|
|
||||||
|
|
||||||
Request DTO `Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs` and response
|
|
||||||
`MediaBrowser.Controller/Authentication/AuthenticationResult.cs` are **byte-identical** between
|
|
||||||
v10.11.5 and v12.0 (`diff` exit 0, no output). The controller method differs only by an added
|
|
||||||
`[Tags("Authentication")]` OpenAPI annotation.
|
|
||||||
|
|
||||||
Note the endpoint still reads the auth context from the request, so the client-identifying
|
|
||||||
`Authorization: MediaBrowser Client=…, DeviceId=…` header must be present on the login call too.
|
|
||||||
|
|
||||||
`UserDto` (returned inside `AuthenticationResult`) has three fields whose **type widened to
|
|
||||||
nullable**, all annotated obsolete:
|
|
||||||
`HasPassword` `bool` → `bool? = true` `[Obsolete("This information is no longer provided")]`;
|
|
||||||
`HasConfiguredPassword` `bool` → `bool? = true` `[Obsolete("This is always true")]`;
|
|
||||||
`HasConfiguredEasyPassword` `bool` → `bool? = false`.
|
|
||||||
Source: `MediaBrowser.Model/Dto/UserDto.cs` diff between the two tags; corroborated by release
|
|
||||||
notes "`UserDto.HasPassword` is marked obsolete and no longer provides useful information".
|
|
||||||
|
|
||||||
### 2.5 `/System/Info/Public` — unchanged endpoint, changed version string
|
|
||||||
|
|
||||||
`MediaBrowser.Model/System/PublicSystemInfo.cs` is **byte-identical** between v10.11.5 and v12.0
|
|
||||||
(`diff` produced no output). Fields in v12.0: `LocalAddress`, `ServerName`, `Version`,
|
|
||||||
`ProductName`, `OperatingSystem`, `Id`, `StartupWizardCompleted`.
|
|
||||||
|
|
||||||
The route `[HttpGet("Info/Public")]` sits at `SystemController.cs:92` in **both** versions, with
|
|
||||||
no `[Authorize]` attribute (anonymous), and is present in the 12.0 OpenAPI spec as
|
|
||||||
`/System/Info/Public`.
|
|
||||||
|
|
||||||
Sources: source diff of both tags; `https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json`
|
|
||||||
|
|
||||||
**The only delta is the value of `Version`:** `"12.0.0"` instead of `"10.11.x"`. Confirmed by the
|
|
||||||
blog: "the server reports its version as `12.0.0`" —
|
|
||||||
`https://jellyfin.org/posts/jellyfin-release-12.0`
|
|
||||||
|
|
||||||
Both uses JellyTau makes of this endpoint (version detection, offline-recovery probe) remain valid.
|
|
||||||
Version *parsing* is the thing to fix.
|
|
||||||
|
|
||||||
### 2.6 `/emby/*` and `/mediabrowser/*` route prefixes removed
|
|
||||||
|
|
||||||
`Jellyfin.Api/Middleware/LegacyEmbyRouteRewriteMiddleware.cs` **exists in v10.11.5 and is deleted
|
|
||||||
in v12.0**. Verified by `grep -rln '/emby' --include='*.cs'` over both trees: the file is listed
|
|
||||||
for 10.11.5 and absent for 12.0.
|
|
||||||
|
|
||||||
Release note: "Legacy route prefixes removed (`/emby/*` and `/mediabrowser/*`). Old third-party
|
|
||||||
clients that rely on them will stop working."
|
|
||||||
Source: `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v12.0`
|
|
||||||
|
|
||||||
**Not applicable to JellyTau** — grep found no `/emby/` or `/mediabrowser/` prefix usage.
|
|
||||||
|
|
||||||
### 2.7 BaseItemDto — purely additive, nothing removed or renamed
|
|
||||||
|
|
||||||
`MediaBrowser.Model/Dto/BaseItemDto.cs` diff between v10.11.5 and v12.0 is **two added fields and
|
|
||||||
nothing else**:
|
|
||||||
|
|
||||||
```diff
|
|
||||||
+ public float? AlbumNormalizationGain { get; set; }
|
|
||||||
+ public string OriginalLanguage { get; set; }
|
|
||||||
```
|
|
||||||
|
|
||||||
Every field the task called out is **declared identically in both versions** (verified by
|
|
||||||
extracting the property declarations from both files):
|
|
||||||
|
|
||||||
| Field | Type (identical in 10.11.5 and 12.0) |
|
|
||||||
|---|---|
|
|
||||||
| `ImageTags` | `Dictionary<ImageType, string>` |
|
|
||||||
| `BackdropImageTags` | `string[]` |
|
|
||||||
| `ParentBackdropImageTags` | `string[]` |
|
|
||||||
| `ParentBackdropItemId` | `Guid?` |
|
|
||||||
| `ParentThumbImageTag` / `ParentPrimaryImageTag` | `string` |
|
|
||||||
| `UserData` | `UserItemDataDto` |
|
|
||||||
| `MediaStreams` | `MediaStream[]` |
|
|
||||||
| `MediaSources` | `MediaSourceInfo[]` |
|
|
||||||
| `RunTimeTicks` | `long?` |
|
|
||||||
| `IndexNumber` | `int?` |
|
|
||||||
| `ParentIndexNumber` | `int?` |
|
|
||||||
| `SeriesId` | `Guid?` |
|
|
||||||
| `SeasonId` | `Guid?` |
|
|
||||||
|
|
||||||
`MediaBrowser.Model/Dto/UserItemDataDto.cs` and `MediaBrowser.Model/Dto/MediaSourceInfo.cs` are
|
|
||||||
**byte-identical** between the two tags.
|
|
||||||
|
|
||||||
`MediaBrowser.Model/Entities/MediaStream.cs` adds two fields — `LocalizedLanguage`,
|
|
||||||
`LocalizedOriginal` — and rewrites the computed `DisplayTitle` to use pre-resolved localized names
|
|
||||||
(this is the `Accept-Language` header support). **No field removed, no type changed.**
|
|
||||||
|
|
||||||
Source: source diff of `v10.11.5` vs `v12.0`.
|
|
||||||
|
|
||||||
### 2.8 PlaybackInfo — request and response shape unchanged; behaviour changed
|
|
||||||
|
|
||||||
`Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs` (the POST body, carrying `DeviceProfile`)
|
|
||||||
is **byte-identical** between v10.11.5 and v12.0. `/Items/{itemId}/PlaybackInfo` is present in the
|
|
||||||
12.0 OpenAPI spec.
|
|
||||||
|
|
||||||
`MediaInfoController.cs` diff is 28 lines, all plumbing:
|
|
||||||
`GetPlaybackInfo(item, user)` → `GetPlaybackInfo(item, user, Request)` (to read `Accept-Language`),
|
|
||||||
and `SortMediaSources(info, maxStreamingBitrate)` → `SortMediaSources(info, maxStreamingBitrate, item.Id)`.
|
|
||||||
|
|
||||||
Source: source diff of `Jellyfin.Api/Controllers/MediaInfoController.cs` and
|
|
||||||
`Jellyfin.Api/Helpers/MediaInfoHelper.cs`.
|
|
||||||
|
|
||||||
### 2.9 DeviceProfile schema — near-identical, two changes
|
|
||||||
|
|
||||||
`MediaBrowser.Model/Dlna/` diff between v10.11.5 and v12.0:
|
|
||||||
|
|
||||||
| File | Result |
|
|
||||||
|---|---|
|
|
||||||
| `DeviceProfile.cs` | **byte-identical** |
|
|
||||||
| `DirectPlayProfile.cs` | **byte-identical** |
|
|
||||||
| `CodecProfile.cs` | **byte-identical** |
|
|
||||||
| `SubtitleProfile.cs` | **byte-identical** |
|
|
||||||
| `ProfileCondition.cs` | **byte-identical** |
|
|
||||||
| `TranscodingProfile.cs` | one change (below) |
|
|
||||||
| `ProfileConditionValue.cs` | one added enum member (below) |
|
|
||||||
|
|
||||||
**Change 1 — `TranscodingProfile.BreakOnNonKeyFrames` retired:**
|
|
||||||
|
|
||||||
```diff
|
|
||||||
[DefaultValue(false)]
|
|
||||||
+ [XmlIgnore]
|
|
||||||
[XmlAttribute("breakOnNonKeyFrames")]
|
|
||||||
- public bool BreakOnNonKeyFrames { get; set; }
|
|
||||||
+ [Obsolete("This is always false")]
|
|
||||||
+ public bool? BreakOnNonKeyFrames { get; set; }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Type widened `bool` → `bool?`.** Also dropped from the copy constructor, dropped from
|
|
||||||
`StreamInfo`, and the `breakOnNonKeyFrames` **query parameter is removed from every streaming
|
|
||||||
endpoint** (`DynamicHlsController`, `VideosController`, `AudioController`,
|
|
||||||
`UniversalAudioController` — 8 method signatures total). Unknown query params are ignored by
|
|
||||||
ASP.NET Core, so a client still sending it is harmless.
|
|
||||||
|
|
||||||
**Change 2 — `ProfileConditionValue` gains `VideoRotation = 26`**, with a matching
|
|
||||||
`TranscodeReason.VideoRotationNotSupported = 1 << 27`. Additive; existing enum values are
|
|
||||||
unchanged (`NumStreams` is still `25`). Release note: "Add VideoRotation profile condition for
|
|
||||||
Android TVs that do not support rotation metadata."
|
|
||||||
|
|
||||||
Source: source diff of both tags; `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v12.0`
|
|
||||||
|
|
||||||
### 2.10 🟠 New: HLS/DASH-container sources are no longer eligible for direct play
|
|
||||||
|
|
||||||
New in `MediaBrowser.Model/Dlna/StreamBuilder.cs` (v12.0):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private const string ManifestContainers = "hls,applehttp,dash";
|
|
||||||
…
|
|
||||||
// A manifest is not a byte stream, so it cannot be handed to the client as one. The variant
|
|
||||||
// and segment URIs inside it are relative to the origin and do not resolve against the
|
|
||||||
// Jellyfin url the client would fetch it from.
|
|
||||||
if (ContainerHelper.ContainsContainer(ManifestContainers, item.Container))
|
|
||||||
{
|
|
||||||
isEligibleForDirectPlay = false;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
A source whose container is `hls`/`applehttp`/`dash` that direct-played on 10.11.5 will now be
|
|
||||||
transcoded/remuxed. Source: `StreamBuilder.cs` diff, hunk `@@ -714,6 +720,14 @@`.
|
|
||||||
|
|
||||||
### 2.11 🟠 TranscodeReasons now reports codec mismatches that 10.11.5 silently omitted
|
|
||||||
|
|
||||||
New in v12.0 `StreamBuilder.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
playlistItem.VideoCodecs = videoCodecs;
|
|
||||||
if (videoStream is not null && !ContainerHelper.ContainsContainer(videoCodecs, false, videoStream.Codec))
|
|
||||||
{
|
|
||||||
playlistItem.TranscodeReasons |= TranscodeReason.VideoCodecNotSupported;
|
|
||||||
}
|
|
||||||
…
|
|
||||||
if (audioStream is not null && audioStreamWithSupportedCodec is null)
|
|
||||||
{
|
|
||||||
playlistItem.TranscodeReasons |= TranscodeReason.AudioCodecNotSupported;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Source: `StreamBuilder.cs` diff, hunks `@@ -944,6 +958,10 @@` and `@@ -992,6 +1010,10 @@`.
|
|
||||||
|
|
||||||
This is a **reporting** improvement: PlaybackInfo responses now carry `VideoCodecNotSupported` /
|
|
||||||
`AudioCodecNotSupported` in cases where 10.11.5 returned an empty or partial reason set. If any
|
|
||||||
JellyTau workaround keys off "TranscodeReasons was empty so the profile must have been honoured",
|
|
||||||
that inference changes. See §3 for what this does **not** establish.
|
|
||||||
|
|
||||||
### 2.12 🔴 `GetItems` now defaults `recursive` to true for library folders with `includeItemTypes`
|
|
||||||
|
|
||||||
New in v12.0 `ItemsController.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
else if (folder is ICollectionFolder && includeItemTypes.Length == 0)
|
|
||||||
{
|
|
||||||
includeItemTypes = collectionType switch { CollectionType.boxsets => [BaseItemKind.BoxSet], _ => [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// includeItemTypes on a library lists its contents recursively rather than just its
|
|
||||||
// immediate children, so default to a recursive query when the client didn't choose.
|
|
||||||
if (folder is ICollectionFolder && includeItemTypes.Length > 0)
|
|
||||||
{
|
|
||||||
recursive ??= true;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
and, at the user root, filtered requests now take the query path:
|
|
||||||
|
|
||||||
```diff
|
|
||||||
-if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder)
|
|
||||||
+if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder || query.HasFilters)
|
|
||||||
```
|
|
||||||
|
|
||||||
Source: `Jellyfin.Api/Controllers/ItemsController.cs` diff (703 lines), hunks `@@ -294,7 +321,22 @@`
|
|
||||||
and `@@ -307,220 +349,273 @@`.
|
|
||||||
|
|
||||||
Release-note wording: "`GetItems` is now asynchronous and applies `recursive` when filters are
|
|
||||||
requested, limited to requests that include `includeItemTypes`. **The same query can return a
|
|
||||||
different result set than it did on 10.11.**"
|
|
||||||
Source: `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v12.0`; also
|
|
||||||
`https://jellyfin.org/posts/jellyfin-release-12.0`
|
|
||||||
|
|
||||||
This applies equally to the deprecated `/Users/{userId}/Items` alias, which routes to the same
|
|
||||||
handler.
|
|
||||||
|
|
||||||
**JellyTau audit item:** any request that sends `ParentId=<library>` **plus** `IncludeItemTypes`
|
|
||||||
**without** an explicit `Recursive` will change behaviour. The hard-coded query strings in
|
|
||||||
`repository/online.rs` all pair `IncludeItemTypes` with `Recursive=true`, but the dynamically
|
|
||||||
appended ones do not obviously do so — check `repository/endpoints.rs:172, 263, 315, 353, 367` and
|
|
||||||
`repository/online.rs:1259, 1492, 2246, 2947`. **Sending `Recursive` explicitly makes the
|
|
||||||
behaviour identical on both generations** — again a rename-class fix, not a capability branch.
|
|
||||||
|
|
||||||
### 2.13 🟠 HLS controllers removed from the OpenAPI spec (routes still live)
|
|
||||||
|
|
||||||
`Jellyfin.Api/Controllers/DynamicHlsController.cs` gains a class-level
|
|
||||||
`[ApiExplorerSettings(IgnoreApi = true)]` in v12.0 (it had none in 10.11.5), as does
|
|
||||||
`HlsSegmentController.cs`. Release note: "The HLS controllers are hidden from the specification."
|
|
||||||
|
|
||||||
**The routes still exist and still work in v12.0**, confirmed in source:
|
|
||||||
|
|
||||||
| Route | v12.0 location |
|
|
||||||
|---|---|
|
|
||||||
| `GET/HEAD /Videos/{itemId}/master.m3u8` | `DynamicHlsController.cs:404-405` |
|
|
||||||
| `GET/HEAD /Audio/{itemId}/master.m3u8` | `DynamicHlsController.cs:577-578` |
|
|
||||||
| `GET /Videos/{itemId}/main.m3u8` | `DynamicHlsController.cs:745` |
|
|
||||||
| `GET /Videos/{itemId}/live.m3u8` | `DynamicHlsController.cs:164` |
|
|
||||||
| `GET /Videos/{itemId}/hls1/{playlistId}/{segmentId}.{container}` | `DynamicHlsController.cs:1086` |
|
|
||||||
|
|
||||||
But they are **absent from the 12.0 OpenAPI spec**. Grepping
|
|
||||||
`https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json` for m3u8/stream/universal paths
|
|
||||||
returns only: `/Audio/{itemId}/stream`, `/Audio/{itemId}/stream.{container}`,
|
|
||||||
`/Audio/{itemId}/universal`, `/Videos/{itemId}/stream`, `/Videos/{itemId}/stream.{container}`,
|
|
||||||
`/Videos/{itemId}/Trickplay/{width}/tiles.m3u8`,
|
|
||||||
`/Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8`, plus LiveTv paths.
|
|
||||||
**`/Videos/{itemId}/master.m3u8` is not among them.**
|
|
||||||
|
|
||||||
Combined with the stated policy ("can be removed in any major release without warning"), JellyTau's
|
|
||||||
transcoded-playback path — which depends on `master.m3u8` — is now on **unspecified-but-functional**
|
|
||||||
footing. It works on 12.0; it carries removal risk for 13.0. This is a risk to track, not a
|
|
||||||
behavioural difference to branch on.
|
|
||||||
|
|
||||||
`GET/HEAD /Audio/{itemId}/universal` (`UniversalAudioController.cs:92-93`) and
|
|
||||||
`/Videos/{itemId}/stream` remain **in** the spec.
|
|
||||||
|
|
||||||
### 2.14 `StartTimeTicks` — unchanged
|
|
||||||
|
|
||||||
`long? startTimeTicks` appears in the same **11 method signatures** across
|
|
||||||
`VideosController.cs`, `AudioController.cs` and `DynamicHlsController.cs` in **both** v10.11.5 and
|
|
||||||
v12.0. Source: grep count over both trees.
|
|
||||||
|
|
||||||
One related fix in `StreamInfo.cs`: the master.m3u8 URL builder no longer emits a stray `?`
|
|
||||||
(10.11.5 appended `"/master.m3u8?"` then later `'?'`/`'&'`; 12.0 appends `"/master.m3u8"` and
|
|
||||||
rewrites the first `&` to `?`). This only affects server-generated URLs.
|
|
||||||
|
|
||||||
### 2.15 🟠 Image endpoints no longer upscale
|
|
||||||
|
|
||||||
New in v12.0 `MediaBrowser.Model/Drawing/DrawingUtils.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
/// Scales a size down uniformly until it fits inside a bounding box.
|
|
||||||
/// Returns the original size if it already fits, so this never upscales.
|
|
||||||
public static ImageDimensions ScaleDownToFit(ImageDimensions size, ImageDimensions boundingBox)
|
|
||||||
```
|
|
||||||
|
|
||||||
Blog: "Artwork is no longer stretched past its real size. Low resolution posters now appear at
|
|
||||||
their actual size instead of being blown up to fit."
|
|
||||||
Sources: `DrawingUtils.cs` diff; `https://jellyfin.org/posts/jellyfin-release-12.0`
|
|
||||||
|
|
||||||
A request for `?fillWidth=400` against a 200px-wide source now returns a ~200px image on 12.0 and a
|
|
||||||
400px image on 10.11.5. Layouts that assume the returned image matches the requested dimensions
|
|
||||||
will see different intrinsic sizes.
|
|
||||||
|
|
||||||
### 2.16 Other confirmed API-surface changes (obsolete-but-functional)
|
|
||||||
|
|
||||||
From `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v12.0`, each verified as an
|
|
||||||
`[Obsolete]` attribute present in the v12.0 controller source (file:line from the extracted tree):
|
|
||||||
|
|
||||||
| Endpoint | Replacement | v12.0 source |
|
|
||||||
|---|---|---|
|
|
||||||
| `GetTrailers` | `GetItems` with `includeItemTypes=Trailer` | `TrailersController.cs:125` |
|
|
||||||
| `GetArtists`, `GetAlbumArtists` | `GetPersons` | `ArtistsController.cs:90, 244` |
|
|
||||||
| `GetArtistByName` | `GetPerson` | `ArtistsController.cs:368` |
|
|
||||||
| `GetMusicGenre` | `GetGenre` | `MusicGenresController.cs:154` |
|
|
||||||
| `GetInstantMixFromMusicGenreBy{Id,Name}` | `GetInstantMixFromItem` | `InstantMixController.cs:199, 363` |
|
|
||||||
| `GetStartupConfiguration`, `UpdateInitialConfiguration`, `SetRemoteAccess` | configuration endpoints | `StartupController.cs:56, 76, 95` |
|
|
||||||
|
|
||||||
Also confirmed from the release notes: "`ItemByName` responses are restricted and people are
|
|
||||||
deduplicated"; sorting by name now uses `SortName`/`CleanName` so library ordering may differ;
|
|
||||||
`.ogg` is audio-only; global subtitle configuration removed in favour of per-library settings.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Section 3 — Unverified / could not establish
|
|
||||||
|
|
||||||
Everything below was actively looked for and **could not be confirmed**. Treat each as unknown.
|
|
||||||
|
|
||||||
1. **Whether 12.0 honours a submitted `DirectPlayProfile`'s declared container and video codec any
|
|
||||||
differently from 10.11.5.** This was the central question behind JellyTau's workarounds and I
|
|
||||||
**cannot answer it.** What I established is narrower: 12.0 *reports* `VideoCodecNotSupported` /
|
|
||||||
`AudioCodecNotSupported` in `TranscodeReasons` where 10.11.5 did not (§2.11), and 12.0 refuses
|
|
||||||
direct play for HLS/DASH-container sources (§2.10). Neither tells you whether the *decision*
|
|
||||||
about a declared container/codec changed. `DirectPlayProfile.cs` is byte-identical and the rest
|
|
||||||
of `StreamBuilder.cs`'s direct-play evaluation shows no relevant change across its 18 diff
|
|
||||||
hunks, which is weak evidence for "no change" — but I did not trace the full decision path, and
|
|
||||||
I did not run either server. **Do not remove any existing workaround on the strength of this
|
|
||||||
report.** Verify empirically against a real 12.0 instance.
|
|
||||||
|
|
||||||
2. **Which specific 10.11.5 profile-ignoring defect each JellyTau workaround exists for.** I did
|
|
||||||
not read the workarounds or their originating issues, so I cannot say whether any is now
|
|
||||||
unnecessary, still necessary, or actively harmful on 12.0.
|
|
||||||
|
|
||||||
3. **Whether `EnableLegacyAuthorization` will be removed entirely in 13.0.** PR #15559 said
|
|
||||||
removal was expected "likely 10.13" (i.e. 13.0 under the new scheme), but that is a 2025-11
|
|
||||||
statement about a plan, not a commitment, and the flag still exists in 12.0. The 12.0 release
|
|
||||||
notes do not restate a removal target.
|
|
||||||
|
|
||||||
4. **Whether real-world 12.0 servers will have `EnableLegacyAuthorization` re-enabled by users.**
|
|
||||||
The setting is user-editable in `system.xml` and some users will flip it back to keep older
|
|
||||||
clients working. A client cannot read this setting (it is not in `/System/Info/Public`), so
|
|
||||||
**there is no way to detect it other than attempting a request and observing 401.** Do not
|
|
||||||
assume "server is 12.0" implies "legacy auth is off".
|
|
||||||
|
|
||||||
5. **The exact HTTP status/body returned when a legacy auth method is rejected.** I did not run a
|
|
||||||
12.0 server. I assume 401 based on the authorization pipeline but **did not verify it**, and I
|
|
||||||
did not establish whether a rejected `X-Emby-Authorization` produces a distinguishable error
|
|
||||||
from an expired token — which matters if you want to auto-detect and re-auth.
|
|
||||||
|
|
||||||
6. **Whether `/Users/{userId}/…` routes emit a deprecation warning header** (e.g. `Deprecation`,
|
|
||||||
`Sunset`, `Warning`) on 12.0. I looked at the controllers and found only `[Obsolete]` /
|
|
||||||
`[ApiExplorerSettings]` compile-time and spec-time attributes. I found no evidence of a runtime
|
|
||||||
response header, but did not exhaustively search the middleware pipeline.
|
|
||||||
|
|
||||||
7. **A 10.11.x OpenAPI document for a true spec-to-spec diff.** `api.jellyfin.org` serves only one
|
|
||||||
spec and it is now `12.0.0`; both the "stable" and "unstable" URLs return the identical
|
|
||||||
1,894,898-byte 12.0 document. The `jellyfin-sdk-typescript` repo's historic `openapi.json` files
|
|
||||||
are **Git LFS pointers**, which I did not resolve. All route/DTO comparisons in this report are
|
|
||||||
therefore from **C# source**, not from two specs. Source-level results should be equivalent or
|
|
||||||
better, but the difference is worth stating.
|
|
||||||
|
|
||||||
8. **Changes to `POST /Sessions/Playing`, `/Sessions/Playing/Progress`, `/Sessions/Playing/Stopped`
|
|
||||||
payload semantics**, and to remote-control / session-polling behaviour. `PlaystateController.cs`
|
|
||||||
shows the routes intact with unchanged obsolete markers, but I did not diff the session
|
|
||||||
manager, `SessionInfo`, or the WebSocket message set. JellyTau's remote mode depends on these
|
|
||||||
and they were **not examined**.
|
|
||||||
|
|
||||||
9. **Whether the `Accept-Language` header support changes any response JellyTau parses.**
|
|
||||||
`MediaStream.DisplayTitle` is now built from server-resolved `LocalizedLanguage` rather than
|
|
||||||
client-side culture lookup, which means `DisplayTitle` **strings will differ** — but I did not
|
|
||||||
determine the default when no `Accept-Language` is sent, nor whether JellyTau parses
|
|
||||||
`DisplayTitle` anywhere.
|
|
||||||
|
|
||||||
10. **Any change to `/Items/{itemId}/Images/{type}` URL parameters** (`tag`, `maxWidth`,
|
|
||||||
`fillHeight`, `quality`). I confirmed the *upscaling* behaviour change (§2.15) but did not diff
|
|
||||||
`ImageController`'s parameter list.
|
|
||||||
|
|
||||||
11. **Download / sync / offline endpoints** (`/Items/{id}/Download`, `/Sync/*`). Not examined.
|
|
||||||
|
|
||||||
12. **`/Videos/{id}/stream` `static=true` semantics** — whether the container/`mediaSourceId`
|
|
||||||
handling changed. `VideosController.cs` has a 207-line diff dominated by the
|
|
||||||
`PrimaryVersionId` `string` → `Guid` refactor and alternate-version relinking; I did not
|
|
||||||
isolate whether any of it alters `static=true` responses.
|
|
||||||
|
|
||||||
13. **Whether 12.0 changes the `DeviceId` single-session constraint** mentioned in the auth gist.
|
|
||||||
Not investigated.
|
|
||||||
|
|
||||||
14. **Actual 12.0 runtime behaviour of anything.** Nothing in this report was tested against a
|
|
||||||
running server of either version. Everything is source, spec, and release-note analysis.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Section 4 — Proposed capability flags
|
|
||||||
|
|
||||||
The strongest finding here is that **most of this needs no flag.** Four of the five headline
|
|
||||||
changes are fixed by writing the request in a way that is correct on *both* generations. Flags
|
|
||||||
should be reserved for genuine either/or behaviour, because each one is a silent branch that will
|
|
||||||
outlive the reason it was added.
|
|
||||||
|
|
||||||
### Needs no flag — fix once, works on both generations
|
|
||||||
|
|
||||||
| Change | Fix | Why no flag |
|
|
||||||
|---|---|---|
|
|
||||||
| §2.3 auth header | `X-Emby-Authorization` → `Authorization`, same value | `Authorization` + `MediaBrowser` scheme is ungated in 10.11.5 and 12.0 |
|
|
||||||
| §2.3 query auth | `api_key=` → `ApiKey=` | `ApiKey` is ungated in both; the server itself emits it in both |
|
|
||||||
| §2.12 recursive default | send `Recursive` explicitly on every `IncludeItemTypes` query | an explicit value makes both generations agree |
|
|
||||||
| §2.9 breakOnNonKeyFrames | stop sending it | ignored as an unknown query param on both |
|
|
||||||
| §2.2 user-scoped routes | optional: migrate to `/Items?userId=` etc. | replacements exist in 10.11.5 too |
|
|
||||||
|
|
||||||
Do these first. They eliminate the entire breaking surface without introducing a single branch.
|
|
||||||
|
|
||||||
### Genuinely version-dependent — flag candidates
|
|
||||||
|
|
||||||
A single detected generation, derived once from `/System/Info/Public` `Version`, should drive these:
|
|
||||||
|
|
||||||
```
|
|
||||||
ServerGeneration::V10_11 // Version major == 10
|
|
||||||
ServerGeneration::V12Plus // Version major >= 12
|
|
||||||
```
|
|
||||||
|
|
||||||
| Flag | Guards | Default 10.11.x | Default 12.x | Source |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `supports_manifest_container_direct_play` | Whether an `hls`/`applehttp`/`dash` source may be direct-played | `true` | `false` | §2.10 |
|
|
||||||
| `reports_codec_transcode_reasons` | Whether an empty/partial `TranscodeReasons` can be read as "profile honoured" | `false` | `true` | §2.11 |
|
|
||||||
| `image_endpoint_upscales` | Whether a requested `fillWidth`/`maxWidth` is the size you get back | `true` | `false` | §2.15 |
|
|
||||||
| `hls_master_playlist_in_spec` | Whether `/Videos/{id}/master.m3u8` is a specified endpoint (removal-risk telemetry, not a behaviour switch) | `true` | `false` | §2.13 |
|
|
||||||
|
|
||||||
### Runtime-probed, not version-derived
|
|
||||||
|
|
||||||
| Flag | Why it cannot be version-derived |
|
|
||||||
|---|---|
|
|
||||||
| `legacy_auth_accepted` | A 12.0 admin can set `EnableLegacyAuthorization=true`, and a 10.11 admin can set it to `false`. Not exposed to clients (§3.4). If JellyTau keeps any legacy-auth fallback, it must be probe-and-observe-401, never version-inferred. **Better: send only non-deprecated auth and delete the concept.** |
|
|
||||||
|
|
||||||
### Version parsing
|
|
||||||
|
|
||||||
Whatever detects the generation must **not** assume a leading `10.`. `/System/Info/Public` returns
|
|
||||||
`"10.11.5"` on one generation and `"12.0.0"` on the other; under the old scheme 12.0 would have been
|
|
||||||
10.12.0, so `major >= 12` and `major == 10` are the two live cases and `major == 11` will never
|
|
||||||
occur. The Jellyfin blog explicitly flags version-string parsers as the thing to check before
|
|
||||||
upgrading (`https://jellyfin.org/posts/jellyfin-release-12.0`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Source index
|
|
||||||
|
|
||||||
| # | URL |
|
|
||||||
|---|---|
|
|
||||||
| 1 | `https://api.github.com/repos/jellyfin/jellyfin/tags` (pages 1-8) |
|
|
||||||
| 2 | `https://api.github.com/repos/jellyfin/jellyfin/releases/latest` |
|
|
||||||
| 3 | `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v12.0` |
|
|
||||||
| 4 | `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v10.11.11` |
|
|
||||||
| 5 | `https://jellyfin.org/posts/jellyfin-release-12.0` |
|
|
||||||
| 6 | `https://jellyfin.org/posts/` |
|
|
||||||
| 7 | `https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json` (`info.version` = 12.0.0) |
|
|
||||||
| 8 | `https://gist.github.com/nielsvanvelzen/ea047d9028f676185832e51ffaf12a6f` (auth methods table) |
|
|
||||||
| 9 | `https://api.github.com/repos/jellyfin/jellyfin/pulls/13306` |
|
|
||||||
| 10 | `https://api.github.com/repos/jellyfin/jellyfin/pulls/15559` |
|
|
||||||
| 11 | `https://api.github.com/repos/jellyfin/jellyfin/pulls/16754` |
|
|
||||||
| 12 | `https://api.github.com/repos/jellyfin/jellyfin/pulls/16992` |
|
|
||||||
| 13 | `https://codeload.github.com/jellyfin/jellyfin/tar.gz/refs/tags/v10.11.5` (full source tree) |
|
|
||||||
| 14 | `https://codeload.github.com/jellyfin/jellyfin/tar.gz/refs/tags/v12.0` (full source tree) |
|
|
||||||
| 15 | `https://raw.githubusercontent.com/jellyfin/jellyfin/v12.0/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs` |
|
|
||||||
| 16 | `https://raw.githubusercontent.com/jellyfin/jellyfin/v10.11.5/MediaBrowser.Model/Configuration/ServerConfiguration.cs` |
|
|
||||||
| 17 | `https://raw.githubusercontent.com/jellyfin/jellyfin/v12.0/MediaBrowser.Model/Configuration/ServerConfiguration.cs` |
|
|
||||||
|
|
||||||
Working files (source trees, diffs, route diff JSON) are retained in the scratchpad alongside this
|
|
||||||
report: `tree/jellyfin-10.11.5/`, `tree/jellyfin-12.0/`, `routediff.json`, `sb.diff`, `items.diff`,
|
|
||||||
`v12-body.md`, `oas-stable.json`.
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
# Spec: Jellyfin server version compatibility
|
|
||||||
|
|
||||||
**Status:** Partially implemented
|
|
||||||
**Requirements:** UR-085 → IR-035, JA-037, DR-279 … DR-288 (DR-287 and DR-288
|
|
||||||
were added once research established what actually breaks).
|
|
||||||
|
|
||||||
## What is left
|
|
||||||
|
|
||||||
Everything below shipped on 2026-09-08 **except**:
|
|
||||||
|
|
||||||
- **DR-283 is partially done.** `supports_manifest_container_direct_play` and
|
|
||||||
`image_endpoint_upscales` are resolved and tested, but **nothing consumes them
|
|
||||||
yet** — and that may be correct rather than an omission: on 12.0 the *server*
|
|
||||||
enforces both (it refuses direct play for manifest containers itself, and
|
|
||||||
simply returns the smaller image), so the client learns the answer from the
|
|
||||||
`PlaybackInfo` response without needing to predict it. Decide whether to
|
|
||||||
consume them or delete them once a running 12.x server can be observed. Do not
|
|
||||||
leave them unread indefinitely: an unconsumed flag is a branch waiting to be
|
|
||||||
wired wrongly.
|
|
||||||
- **`honours_directplay_audio_codec` is unresolved for 12.x.** A source-level
|
|
||||||
diff could not establish whether the behaviour changed. The override stays on
|
|
||||||
for both generations. Flip it only against a running 12.x server — keeping it
|
|
||||||
costs an unnecessary transcode, removing it wrongly costs silent playback.
|
|
||||||
- **The user-scoped route migration (DR-282) was not performed.** It is not
|
|
||||||
needed: the whole family still works on 12.0. Both route shapes are built and
|
|
||||||
tested, so switching is a one-line change whenever it is wanted.
|
|
||||||
- **Nothing was tested against a real server of either generation.** Every
|
|
||||||
cross-generation assertion runs against a mock built from a source-level diff.
|
|
||||||
|
|
||||||
## What research established
|
|
||||||
|
|
||||||
The framing this spec was written under was wrong in a way worth recording.
|
|
||||||
|
|
||||||
**Jellyfin 11.0 does not exist and never did.** With 12.0 the project dropped the
|
|
||||||
leading `10` from its version scheme: what would have been 10.12.0 shipped as
|
|
||||||
`12.0`, and the server reports `Version: "12.0.0"`. So "two generations" means
|
|
||||||
**10.11.x and 12.x**, one release-branch step apart, not two majors. 12.0 became
|
|
||||||
stable on 2026-09-08 — the same day this work was done — so real-world 12.x
|
|
||||||
installs are currently near zero and rising.
|
|
||||||
|
|
||||||
The delta is far smaller than this spec assumed, and almost none of it is a
|
|
||||||
branch:
|
|
||||||
|
|
||||||
| Finding | Consequence |
|
|
||||||
|---|---|
|
|
||||||
| `X-Emby-Authorization` and the `api_key` query parameter are **disabled by default in 12.0**, including on upgraded servers via a migration | The one genuinely breaking change. Fixed by a **rename** — `Authorization` + `ApiKey` are ungated on both — not a flag (DR-287) |
|
|
||||||
| `GetItems` now defaults `recursive` to true for a library parent with `IncludeItemTypes` | The same request returns a different result set. Fixed by stating `Recursive` explicitly (DR-288) |
|
|
||||||
| The `/Users/{userId}/…` family **survives** in 12.0 | No migration needed. Six routes were removed in total; none are ones this client calls |
|
|
||||||
| `BaseItemDto` is **purely additive**; `DeviceProfile`, `PlaybackInfo`, `PublicSystemInfo` byte-identical | No DTO work at all |
|
|
||||||
| Manifest-container sources are no longer direct-play eligible; image endpoints no longer upscale | The only two genuine either/or differences — and both are server-enforced |
|
|
||||||
|
|
||||||
The lesson for the layer rule: **most of a version delta is fixed by writing the
|
|
||||||
request correctly for both generations, not by branching on the version.** Flags
|
|
||||||
are for genuine either/or behaviour, because each one is a silent branch that
|
|
||||||
outlives the reason it was added.
|
|
||||||
|
|
||||||
The full report, with a source URL per claim, is
|
|
||||||
[jellyfin-12-api-delta.md](jellyfin-12-api-delta.md).
|
|
||||||
**UX spec:** n/a for the bulk of it. One new user-visible state — "this server
|
|
||||||
is a version JellyTau does not know" — needs a home in the connect flow; see
|
|
||||||
DR-286.
|
|
||||||
**Supersedes / revises:** nothing. Touches
|
|
||||||
[backend-owned-stream-selection.md](backend-owned-stream-selection.md) at the
|
|
||||||
`StreamSelection` boundary and should land after it where they overlap, but
|
|
||||||
neither blocks the other.
|
|
||||||
|
|
||||||
**Destination on completion:**
|
|
||||||
[01-rust-backend.md](../architecture/01-rust-backend.md) — a new "Server
|
|
||||||
capability negotiation" section beside "Domain Vocabulary Owned by Rust", which
|
|
||||||
is where the litmus test this feature exists to satisfy already lives; and a
|
|
||||||
paragraph in [07-connectivity.md](../architecture/07-connectivity.md) noting
|
|
||||||
that the `/System/Info/Public` probe now has a second consumer. The durable half
|
|
||||||
is the capability model and *why* it is flags rather than version comparisons;
|
|
||||||
phases, ticket boundaries and acceptance criteria are disposable.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Let one build of JellyTau talk to more than one generation of Jellyfin server.
|
|
||||||
The app already asks the server what version it is, at connect, before login —
|
|
||||||
and then throws the answer away. Instead it resolves that version into a
|
|
||||||
`ServerCapabilities` value once per connection, and every decision that depends
|
|
||||||
on the server generation reads a named flag from it.
|
|
||||||
|
|
||||||
Nothing about the app changes for a user whose server matches what the code
|
|
||||||
targets today. What changes is that the release which follows the server forward
|
|
||||||
stops silently abandoning everyone who has not upgraded, and that a server the
|
|
||||||
app does not recognise produces a sentence rather than a cascade of parse
|
|
||||||
failures.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
The server and its clients are upgraded by different people on different
|
|
||||||
schedules. A family server can sit a major version behind for a year while the
|
|
||||||
phone updates itself weekly. Today the code has no way to express that.
|
|
||||||
|
|
||||||
**1. One server generation is hard-coded, unconditionally.** The current target
|
|
||||||
is 10.11.5 and it is written into the code as fact, not as a branch —
|
|
||||||
[device_profile.rs:336](../../src-tauri/src/repository/device_profile.rs#L336),
|
|
||||||
[online.rs:966](../../src-tauri/src/repository/online.rs#L966),
|
|
||||||
[online.rs:2271](../../src-tauri/src/repository/online.rs#L2271), and most
|
|
||||||
pointedly [online.rs:4667](../../src-tauri/src/repository/online.rs#L4667),
|
|
||||||
which is documented as "the override that exists because Jellyfin 10.11.5
|
|
||||||
ignores…". Every one of those is correct for one server and wrong for another,
|
|
||||||
and there is nowhere to say which.
|
|
||||||
|
|
||||||
**2. Endpoints are 57 inline string literals, not a route table.** They are
|
|
||||||
built with `format!` at the point of use, query string and all —
|
|
||||||
[online.rs:1957](../../src-tauri/src/repository/online.rs#L1957) is
|
|
||||||
representative. Supporting a second route shape without a table means 57
|
|
||||||
conditionals rather than one.
|
|
||||||
|
|
||||||
**3. The legacy user-scoped routes are load-bearing.** Roughly twelve sites use
|
|
||||||
`/Users/{uid}/Items`, `/Users/{uid}/Items/Resume`, `/Users/{uid}/Views`,
|
|
||||||
`/Users/{uid}/FavoriteItems/{id}` and `/Users/{uid}/PlayedItems/{id}`. These are
|
|
||||||
precisely the routes upstream has been moving away from in favour of
|
|
||||||
`/Items?userId=`. Whichever release drops them takes the app with it.
|
|
||||||
|
|
||||||
**4. There is no way to test any of this.** `src-tauri/` contains no HTTP mocking
|
|
||||||
at all — no `wiremock`, no `mockito`, no `httpmock`. Every test of the online
|
|
||||||
repository asserts on a *constructed URL string*; not one exercises a response.
|
|
||||||
So there is currently no mechanism by which "works against both generations"
|
|
||||||
could be demonstrated, and this is the single largest item in the work. It is
|
|
||||||
also worth doing on its own merits: a 4,797-line adapter with no response-level
|
|
||||||
tests is under-covered regardless of how many server versions it supports.
|
|
||||||
|
|
||||||
**5. A Jellyfin route is being built in the frontend.**
|
|
||||||
[imageCache.ts:64](../../src/lib/services/imageCache.ts#L64) constructs
|
|
||||||
`${serverUrl}/Items/${itemId}/Images/${imageType}` in Svelte. By the litmus test
|
|
||||||
in this project's own spec template — *would this have to change if Jellyfin
|
|
||||||
changed its API?* — that is domain logic in the presentation layer. It is the
|
|
||||||
only one left, and this is the feature that makes it actively wrong rather than
|
|
||||||
merely misplaced.
|
|
||||||
|
|
||||||
**What this is not.** It is not multi-server support. Profiles are users on one
|
|
||||||
server ([profiles/store.rs](../../src-tauri/src/profiles/store.rs)), and that
|
|
||||||
does not change here. "Both versions at the same time" means one binary that
|
|
||||||
adapts to whichever server it is pointed at, not two servers connected at once.
|
|
||||||
|
|
||||||
## Layer assignment
|
|
||||||
|
|
||||||
| Logic / responsibility | Layer | Why it belongs there |
|
|
||||||
|------------------------|-------|----------------------|
|
|
||||||
| Server version string → capability flags | Rust | Domain vocabulary in the strictest sense: it changes when and only when Jellyfin's API changes. The template's litmus test answers this in one word. |
|
|
||||||
| Which route shape to use for a given call | Rust | Wire format. The frontend must not know that a route exists, let alone that there are two. |
|
|
||||||
| Image URL construction (**moving** out of `imageCache.ts`) | Rust | A Jellyfin route, therefore it changes with Jellyfin's API. Currently in the frontend; this feature is what turns that from untidy into broken. |
|
|
||||||
| Device-profile / `PlaybackInfo` override selection | Rust | Already Rust and staying there. Only the *gating* is new — the overrides stop being unconditional. |
|
|
||||||
| Whether a cache written against one server generation is still valid | Rust | A storage invariant. The frontend cannot see the server version and must not learn to. |
|
|
||||||
| Deciding a server is too old / too new to use | Rust | A domain judgement about an API, expressed as an opaque state on the wire. |
|
|
||||||
| How the "unsupported server" state is worded and where it appears in the connect flow | Frontend | Pure presentation. It changes if the UI is redesigned and not otherwise. The frontend renders an opaque state; it never compares a version. |
|
|
||||||
|
|
||||||
Borderline: none. The one row that could be argued is the last, and it splits
|
|
||||||
cleanly — Rust decides *that* the server is unsupported, the frontend decides
|
|
||||||
what that looks like. The frontend never receives a version number to reason
|
|
||||||
about, for the same reason it never receives an item-type list.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### `ServerCapabilities`
|
|
||||||
|
|
||||||
Resolved once, at connect, from a version the app already has.
|
|
||||||
`AuthManager::connect_to_server` ([auth/mod.rs:147](../../src-tauri/src/auth/mod.rs#L147))
|
|
||||||
already parses `PublicSystemInfo.version` and returns it in `ServerInfo`, and the
|
|
||||||
`servers` table already has a `version TEXT` column
|
|
||||||
([schema.rs:42](../../src-tauri/src/storage/schema.rs#L42)) that is written on
|
|
||||||
insert. Detection therefore costs nothing new; the value is simply discarded
|
|
||||||
today.
|
|
||||||
|
|
||||||
The resolved value hangs on `OnlineRepository` and is passed to the route table.
|
|
||||||
`OfflineRepository` has no server and no capabilities; `HybridRepository`
|
|
||||||
delegates. No `MediaRepository` method signature changes, so no caller above
|
|
||||||
`repository/` is touched.
|
|
||||||
|
|
||||||
**Flags, not comparisons.** Every capability is named for the behaviour it
|
|
||||||
governs — `user_scoped_item_routes`, `honours_directplay_container`,
|
|
||||||
`playback_info_respects_container` — and the version → flags mapping lives in
|
|
||||||
exactly one function. A `version < 11` scattered through call sites is the same
|
|
||||||
mistake as a taxonomy in the frontend: it re-derives a domain fact at the point
|
|
||||||
of use, and it is unreadable at the second occurrence. Flags also survive the
|
|
||||||
case the comparison cannot express, which is a backport.
|
|
||||||
|
|
||||||
### Route table
|
|
||||||
|
|
||||||
The ~30 distinct endpoints move into `repository/endpoints.rs`, each a function
|
|
||||||
taking `&ServerCapabilities` and returning the path. Everything in `online.rs`
|
|
||||||
already funnels through three helpers that take `endpoint: &str` —
|
|
||||||
`get_json`, `post_json`, `post_json_response`
|
|
||||||
([online.rs:315-459](../../src-tauri/src/repository/online.rs#L315-L459)) — so
|
|
||||||
the interception point exists and there are 32 call sites, not 57 literals.
|
|
||||||
|
|
||||||
This step is behaviour-preserving on its own and lands before anything depends
|
|
||||||
on it.
|
|
||||||
|
|
||||||
### Unknown versions
|
|
||||||
|
|
||||||
An unrecognised version resolves to the newest known capability set and is
|
|
||||||
recorded, not rejected — the app should keep working against a server that is
|
|
||||||
merely newer than the release. Rejection is reserved for a version below the
|
|
||||||
floor, where the failure is certain rather than likely. Either way the outcome
|
|
||||||
crosses the IPC boundary as an opaque state, never a version number.
|
|
||||||
|
|
||||||
### Cache validity
|
|
||||||
|
|
||||||
Cached rows carry no record of which server generation wrote them. The server's
|
|
||||||
version goes on the cache alongside the existing `synced_at`, and a change
|
|
||||||
invalidates by clearing `synced_at` — the same move
|
|
||||||
[MIGRATION_018 and migration 025](../../src-tauri/src/storage/schema.rs) already
|
|
||||||
make, and for the same reason: the association was never stored, so existing rows
|
|
||||||
cannot be repaired locally and must be re-fetched.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- **Multi-server support.** One server per install, as today.
|
|
||||||
- **Emby, or any non-Jellyfin server.** The capability model would carry it; the
|
|
||||||
DTO layer would not, and nothing here should be read as a step toward it.
|
|
||||||
- **The Windows/Linux/Android split.** Capabilities describe the *server*, never
|
|
||||||
the client platform. Platform differences stay in `device_profile.rs`.
|
|
||||||
- **Raising coverage of the whole online adapter.** The mock-server harness makes
|
|
||||||
that possible and the version-sensitive paths get tests; a general backfill is
|
|
||||||
separate work.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [ ] The app connects, browses, plays and reports against both target server
|
|
||||||
generations, from one build, with no user-visible configuration.
|
|
||||||
- [ ] The repository suite runs against both generations' fixtures and passes.
|
|
||||||
- [ ] No `format!` endpoint literal remains in `online.rs`.
|
|
||||||
- [ ] No version comparison exists outside the single version → capabilities
|
|
||||||
function.
|
|
||||||
- [ ] A server below the supported floor produces one legible message; a server
|
|
||||||
newer than the release still works.
|
|
||||||
- [ ] `bun run check` and `bun run test` pass.
|
|
||||||
- [ ] `cargo fmt` clean, `cargo clippy --all-targets -D warnings` clean,
|
|
||||||
`bun run test:rust` passes.
|
|
||||||
- [ ] `bun run check:boundary` passes — and note it will *not* catch the
|
|
||||||
`imageCache.ts` route, which is why DR-285 is a ticket rather than a
|
|
||||||
tripwire.
|
|
||||||
- [ ] New requirement-implementing code carries `// TRACES:` comments;
|
|
||||||
`bun run traces:validate` passes and coverage does not fall.
|
|
||||||
- [ ] `bindings.ts` regenerated if Rust types changed.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
The harness is the feature's precondition, not its afterthought.
|
|
||||||
|
|
||||||
**Rust.** Add a mock HTTP server (`wiremock` — a project dependency, so no CI
|
|
||||||
image change; see the toolchain rule in CLAUDE.md) plus one recorded fixture set
|
|
||||||
per server generation. The repository suite becomes parameterised over
|
|
||||||
generations. What must be covered: route selection per capability; the
|
|
||||||
device-profile overrides firing on the generation they were written for and *not*
|
|
||||||
on the other; cache invalidation across a version change; an unknown version
|
|
||||||
resolving forward rather than failing.
|
|
||||||
|
|
||||||
**Frontend.** `imageCache.ts` loses its URL construction, so its tests assert it
|
|
||||||
calls the command rather than that it builds a string.
|
|
||||||
|
|
||||||
`repository/online_integration_test.rs` **has been deleted** (2026-09-08). It was
|
|
||||||
never declared in `repository/mod.rs` and referenced a `crate::api::jellyfin`
|
|
||||||
module that does not exist, so it had never compiled. It is worth knowing why it
|
|
||||||
was not merely dead but harmful: its mock *reimplemented* the URL builders and
|
|
||||||
then asserted against itself, and `online.rs` carries a comment recording that
|
|
||||||
this exact arrangement once shipped a `/Videos/{id}/download` endpoint that 404s
|
|
||||||
on real servers while the mock happily tested the correct one — silently breaking
|
|
||||||
every movie and TV download. Its own `test_image_url_basic` asserted `api_key=`
|
|
||||||
appears in image URLs while the mock beside it documented the opposite.
|
|
||||||
|
|
||||||
That is the anti-pattern DR-281 exists to replace: assert against a *response*
|
|
||||||
from a mock **server**, never against a mock that re-derives the thing under
|
|
||||||
test.
|
|
||||||
|
|
||||||
## TRACES
|
|
||||||
|
|
||||||
| Piece | Suggested tag |
|
|
||||||
|---|---|
|
|
||||||
| `ServerCapabilities` + version resolution | `UR-085 \| IR-035, DR-280 \| UT-xxx` |
|
|
||||||
| `repository/endpoints.rs` | `UR-085 \| DR-279` |
|
|
||||||
| Route selection for user-scoped endpoints | `UR-085 \| JA-037, DR-282` |
|
|
||||||
| Capability-gated profile overrides | `UR-085 \| DR-283` |
|
|
||||||
| Cache generation stamp + invalidation | `UR-085 \| DR-284` |
|
|
||||||
| Image URL command | `UR-012, UR-085 \| DR-285` |
|
|
||||||
| Unsupported-server state | `UR-085 \| DR-286` |
|
|
||||||
|
|
||||||
## Notes for the implementer
|
|
||||||
|
|
||||||
- **The concrete API delta is not in this spec, deliberately.** No route, field
|
|
||||||
or behaviour difference between the two generations is asserted here, because
|
|
||||||
none has been verified against an upstream changelog. The first ticket exists
|
|
||||||
to establish it. Do not let a plausible-sounding difference enter the code
|
|
||||||
without a citation — a wrong capability flag is worse than none, since it fires
|
|
||||||
silently on the generation it was not tested against.
|
|
||||||
- The route table and the capability struct are independently useful and
|
|
||||||
independently reviewable. If the feature is cut, cut from the end, not the
|
|
||||||
start.
|
|
||||||
- A parallel Claude session may be active in this repo — `git diff` before
|
|
||||||
"repairing" unexpected changes.
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
# Spec: Migrate to libmpv2 and declare the project licence
|
|
||||||
|
|
||||||
**Status:** Partially implemented — the `LICENSE` file has landed (part 2). The
|
|
||||||
`libmpv` → `libmpv2` swap (part 1) is **not** done: `src-tauri/Cargo.toml` still
|
|
||||||
pins the abandoned crate to a git branch.
|
|
||||||
**Requirements:** UR-003 → IR-003 (revises the MPV integration); no new user-facing behaviour
|
|
||||||
**UX spec:** n/a
|
|
||||||
**Supersedes / revises:** dependency and licensing housekeeping identified in [playback-backend-unification.md](playback-backend-unification.md)
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Two related pieces of housekeeping that block or complicate later work:
|
|
||||||
|
|
||||||
1. Replace the abandoned `libmpv` crate (pinned to a git branch) with the
|
|
||||||
maintained `libmpv2`.
|
|
||||||
2. Add a `LICENSE` file. The project has none, which leaves its legal status
|
|
||||||
undefined while it links GPL-licensed libmpv.
|
|
||||||
|
|
||||||
Neither changes user-visible behaviour. Both are prerequisites for
|
|
||||||
[windows-native-audio-backend.md](windows-native-audio-backend.md).
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
### The dependency is dead
|
|
||||||
|
|
||||||
```toml
|
|
||||||
# src-tauri/Cargo.toml
|
|
||||||
libmpv = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", branch = "master" }
|
|
||||||
```
|
|
||||||
|
|
||||||
- crates.io `libmpv` 2.0.1 was published **2020-09-29**.
|
|
||||||
- The upstream repo's last commit was **2023-01-08**; nothing since was released.
|
|
||||||
- We pin a git *branch*, so builds are not reproducible — the same lockfile-less
|
|
||||||
checkout can resolve differently over time, and CI has no protection if the
|
|
||||||
branch moves or the repo disappears.
|
|
||||||
|
|
||||||
`libmpv2` (kohsine/libmpv2-rs) is a maintained fork of exactly this crate:
|
|
||||||
6.0.0 released **2026-05-12**, ~23.5k recent downloads against the original's
|
|
||||||
~1.1k, releases roughly quarterly since 2024.
|
|
||||||
|
|
||||||
### The project has no licence
|
|
||||||
|
|
||||||
There is no `LICENSE`/`COPYING` file and `src-tauri/Cargo.toml` has no `license`
|
|
||||||
field. The project is open source and will never be commercial, so this is purely
|
|
||||||
an omission — but it matters because we link libmpv, and "no licence" defaults to
|
|
||||||
*all rights reserved*, which is incompatible with distributing a GPL-derived
|
|
||||||
work.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### Part 1 — licence
|
|
||||||
|
|
||||||
**Use GPLv3.** This is forced, not chosen:
|
|
||||||
|
|
||||||
- mpv's default build is **GPLv2-or-later**, so the combined work must be
|
|
||||||
GPL-compatible.
|
|
||||||
- Apache-2.0 is **GPLv2-incompatible** (patent-termination and indemnification
|
|
||||||
clauses) but GPLv3-compatible.
|
|
||||||
- A scan of the dependency tree found Apache-2.0-**only** crates with no
|
|
||||||
alternative arm — most importantly **`tao`** (Tauri's own windowing crate),
|
|
||||||
plus `sync_wrapper`, `gethostname`, and `ring` (Apache-2.0 AND ISC).
|
|
||||||
|
|
||||||
`tao` is unavoidable in a Tauri app, so GPLv2 is unavailable. Exercising mpv's
|
|
||||||
"or later" option puts the combination at **GPLv3**.
|
|
||||||
|
|
||||||
Actions:
|
|
||||||
- Add `LICENSE` containing the GPLv3 text.
|
|
||||||
- Add `license = "GPL-3.0-or-later"` to `src-tauri/Cargo.toml` and `license` to
|
|
||||||
`package.json`.
|
|
||||||
- Note in the README that the binary links libmpv (GPLv2+) and FFmpeg.
|
|
||||||
|
|
||||||
Because the project is open source, we use mpv's **default GPL build** — no
|
|
||||||
`-Dgpl=false`, no LGPL FFmpeg build, and none of the LGPL §6 relinking analysis
|
|
||||||
that a proprietary app would need. We keep VAAPI/VDPAU/X11 and every GPL FFmpeg
|
|
||||||
filter.
|
|
||||||
|
|
||||||
🔴 Never build FFmpeg with `--enable-nonfree` — that produces a binary that is
|
|
||||||
**unredistributable under any licence**, open source or not.
|
|
||||||
|
|
||||||
### Part 2 — libmpv → libmpv2
|
|
||||||
|
|
||||||
```toml
|
|
||||||
# Linux (and later Windows, per the Windows audio spec)
|
|
||||||
libmpv2 = "=6.0.0"
|
|
||||||
```
|
|
||||||
|
|
||||||
Pin exactly: `libmpv2` has broken its API in **every** major release.
|
|
||||||
|
|
||||||
Breaking changes to expect, from the changelog:
|
|
||||||
|
|
||||||
| Version | Change | Impact here |
|
|
||||||
|---|---|---|
|
|
||||||
| 4.0.0 | Removed command helper methods — call `mpv.command(...)` directly | Low; we already use `command`/`set_property` |
|
|
||||||
| 5.0.0 | Removed `mpv_node` support entirely (properties return strings; parse JSON yourself); `EventContext` folded into `Mpv`; `ProtocolContext` → `Protocol` | **Medium** — `start_event_loop` uses `create_event_context()`; check whether that call still exists |
|
|
||||||
| 6.0.0 | `RenderContext::new()` → `Mpv::create_render_context()`; `'static` bound on `OpenGLInitParams`; render context now borrows `Mpv` (fixes a use-after-free) | **None** — we do not use the render API |
|
|
||||||
|
|
||||||
The last row matters: we run mpv audio-only (`video = no`), so the entire render
|
|
||||||
surface is irrelevant to us. Consider disabling the default `render` feature to
|
|
||||||
reduce build surface.
|
|
||||||
|
|
||||||
The main porting work is the event loop in `mpv_backend.rs` — `wait_event`,
|
|
||||||
`disable_deprecated_events`, and the `FileLoaded` / `PlaybackRestart` /
|
|
||||||
`PropertyChange` / `EndFile` handling, given 5.0.0 folded `EventContext` into
|
|
||||||
`Mpv`.
|
|
||||||
|
|
||||||
Everything else — `set_property` calls, the `af` filter graph, the 250ms position
|
|
||||||
thread, the seek-suppression window — should port unchanged.
|
|
||||||
|
|
||||||
## Layer assignment
|
|
||||||
|
|
||||||
No logic moves. This is a dependency swap plus a licence file; the
|
|
||||||
`PlayerBackend` trait boundary is untouched.
|
|
||||||
|
|
||||||
| Logic / responsibility | Layer | Why it belongs there |
|
|
||||||
|------------------------|-------|----------------------|
|
|
||||||
| mpv event → `PlayerStatusEvent` mapping | Rust (unchanged) | Already correct; only the binding API beneath it changes. |
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- Any behaviour change. If playback behaves differently after this, that is a bug.
|
|
||||||
- Windows support — separate spec, but this must land first.
|
|
||||||
- Adopting the render API. We are audio-only on mpv.
|
|
||||||
- Re-licensing decisions beyond adding the file the project already implies.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [ ] `LICENSE` (GPLv3) present; `license` field set in `Cargo.toml` and `package.json`.
|
|
||||||
- [ ] A full dependency-licence audit has been run (`cargo install cargo-license && cargo license`) and confirms no GPLv3-incompatible dependency. *(The scan behind this spec resolved 441 of 575 crates from the local registry cache; the remaining 134 are unverified.)*
|
|
||||||
- [ ] `libmpv` git dependency removed; `libmpv2` pinned to an exact version.
|
|
||||||
- [ ] Linux audio playback works identically: play/pause/seek/volume, queue advance, gapless, EQ, normalization, sleep timer.
|
|
||||||
- [ ] Position updates still arrive at 250ms; the 150ms post-seek suppression still prevents the jump-to-zero glitch.
|
|
||||||
- [ ] `EndFile` still emits `PlaybackEnded` only for EOF (not STOP/QUIT/ERROR) — autoplay depends on this.
|
|
||||||
- [ ] Builder image updated if the libmpv dev package requirement changed; **no toolchain install added to any CI step**.
|
|
||||||
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass.
|
|
||||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
The existing `mpv_backend_test.rs` plus the `build_af_filter`,
|
|
||||||
`eq_filter_entries`, and `normalize_filter_entry` tests are the regression net —
|
|
||||||
they must pass unchanged, since none of them touch the binding API.
|
|
||||||
|
|
||||||
The event loop has no unit tests and is where the risk concentrates. Verify
|
|
||||||
manually on Linux:
|
|
||||||
|
|
||||||
1. Play → pause → play; confirm position does not flash to 0:00 (the known
|
|
||||||
playing-event regression).
|
|
||||||
2. Seek mid-track; confirm no jump-to-zero within 150ms.
|
|
||||||
3. Let a track end naturally; confirm autoplay advances (exercises `EndFile` EOF).
|
|
||||||
4. Press stop; confirm autoplay does **not** advance.
|
|
||||||
5. Sleep-timer expiry; confirm it stops without triggering autoplay.
|
|
||||||
|
|
||||||
Cases 3–5 are the ones most likely to break silently, and each corresponds to a
|
|
||||||
bug already fixed once in this codebase.
|
|
||||||
|
|
||||||
## TRACES
|
|
||||||
|
|
||||||
- `MpvBackend` construction / event loop → existing `// TRACES: UR-003 | IR-003`, unchanged
|
|
||||||
- No new requirement IDs; this is a dependency migration.
|
|
||||||
|
|
||||||
## Notes for the implementer
|
|
||||||
|
|
||||||
- Do this **before** the Windows audio backend.
|
|
||||||
- Read the 4.0/5.0/6.0 changelogs before writing code — the crate has broken API
|
|
||||||
in every major release, most recently two months before this spec.
|
|
||||||
- The crates.io `repository` field for `libmpv2` points at `kohsine/libmpv-rs`,
|
|
||||||
but the repo was renamed to **`libmpv2-rs`**; the old raw URLs 404.
|
|
||||||
- `libmpv2-sys` ships pregenerated bindings and vendored headers, so no libclang
|
|
||||||
is needed at build time — relevant to keeping the builder image thin.
|
|
||||||
- A parallel Claude session may be active — `git diff` before "repairing"
|
|
||||||
unexpected changes.
|
|
||||||
@@ -1,503 +0,0 @@
|
|||||||
# Spec: Linux native video — bounded compositing spike
|
|
||||||
|
|
||||||
**Status:** **Run 2026-08-21 — compositing works; G5 carries an open crash.**
|
|
||||||
The compositing claim it set out to test is falsified on Linux. See "Result".
|
|
||||||
This file stays open until the implementation spec exists. **ABR is resolved** —
|
|
||||||
the playlist carries one `EXT-X-STREAM-INF`, so finding 3 is false and there is
|
|
||||||
no adaptation for mpv to lose. The remaining blocker is the unexplained SIGSEGV
|
|
||||||
under G5, which is a lifetime problem, not a compositing one.
|
|
||||||
**Requirements:** none allocated. This spike produces a decision record, not
|
|
||||||
product code — same shape as
|
|
||||||
[playback-backend-unification.md](playback-backend-unification.md), which is
|
|
||||||
Accepted with no requirement ids of its own. Ids are allocated by the
|
|
||||||
*implementation* spec that follows a green result.
|
|
||||||
**UX spec:** n/a
|
|
||||||
**Supersedes / revises:** re-opens finding 2 of
|
|
||||||
[playback-backend-unification.md](playback-backend-unification.md) on Linux only.
|
|
||||||
Its findings 3, 4, 5 and 6 stand unchallenged and are **not** in scope here.
|
|
||||||
|
|
||||||
**Destination on completion:**
|
|
||||||
[05-platform-backends.md](../architecture/05-platform-backends.md) — a "Native
|
|
||||||
Video Compositing (Linux)" section alongside the existing Android one. The
|
|
||||||
durable half is the mechanism and the two traps below; the gates and phases are
|
|
||||||
disposable.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Test one falsifiable claim: *a native video surface cannot be composited with a
|
|
||||||
Tauri webview on Linux.* The claim is load-bearing — it is why Linux video goes
|
|
||||||
through an h264 HLS transcode into a WebKitGTK `<video>` element instead of
|
|
||||||
decoding directly in the mpv instance we already run. The spike renders one mpv
|
|
||||||
frame beneath the webview, on both X11 and Wayland, and stops. It ships no
|
|
||||||
product code and flips no defaults.
|
|
||||||
|
|
||||||
A green result does **not** authorise native video on Linux; it authorises
|
|
||||||
writing the spec that would.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
[playback-backend-unification.md](playback-backend-unification.md) finding 2
|
|
||||||
concluded that native video cannot be composited with a Tauri webview, on
|
|
||||||
evidence from `tauri-plugin-libmpv`'s platform table, wry#284, tauri#6343, and a
|
|
||||||
Tauri maintainer's 2024 statement that a GTK widget as a child X11 window is
|
|
||||||
"a bit hacky and it is not possible on Wayland at all."
|
|
||||||
|
|
||||||
Two things have changed since that was written, and one thing was never tested.
|
|
||||||
|
|
||||||
**1. The general claim has already been falsified on one platform — by us.**
|
|
||||||
Android now renders ExoPlayer video on a TextureView at index 0 *behind a
|
|
||||||
transparent Tauri WebView*, with the Svelte controls drawn over it, on by
|
|
||||||
default. See
|
|
||||||
[05-platform-backends.md](../architecture/05-platform-backends.md#native-video-compositing-android).
|
|
||||||
That is exactly the composition finding 2 said was impossible, shipped. What
|
|
||||||
survives of the finding is a narrower, WebKitGTK-specific claim — which is worth
|
|
||||||
testing on its own terms rather than inheriting.
|
|
||||||
|
|
||||||
**2. A Tauri app now ships Linux native mpv as an active platform.**
|
|
||||||
[MaxVideoPlayer](https://github.com/MaxMB15/MaxVideoPlayer) (354 commits) embeds
|
|
||||||
libmpv via **EGL + X11 child window / Wayland subsurface**, with Linux and macOS
|
|
||||||
active and Windows only planned — the inverse of the plugin matrix finding 2
|
|
||||||
sampled. Its existence does not prove our case works, but it does mean the
|
|
||||||
Wayland half of the maintainer quote is out of date.
|
|
||||||
|
|
||||||
**3. The render API was never tested.** Every source in finding 2 describes
|
|
||||||
*foreign-window embedding*: `--wid`, child windows, a second toplevel
|
|
||||||
position-synced to a `getBoundingClientRect()` div. That is a different mechanism
|
|
||||||
from mpv's render API, where **we** own the GL context and mpv draws into an FBO
|
|
||||||
we hand it (`mpv_render_context_create` / `mpv_render_context_render`, with an
|
|
||||||
upstream [GTK example](https://github.com/mpv-player/mpv-examples/pull/44/files)).
|
|
||||||
Tauri v2 exposes `WebviewWindow::gtk_window()` and `default_vbox()`, so the
|
|
||||||
target is a widget inside Tauri's own GTK tree — not a foreign window, not a
|
|
||||||
second toplevel, and therefore not the thing that was found broken.
|
|
||||||
|
|
||||||
The prize is direct play: no h264 transcode, hardware decode, libass subtitles,
|
|
||||||
and no server CPU burned on every Linux play.
|
|
||||||
|
|
||||||
## The blocker a green spike does not clear
|
|
||||||
|
|
||||||
🔴 **Read this before treating a green result as a green light.**
|
|
||||||
|
|
||||||
Finding 3 of the unification spec stands: **mpv has no adaptive bitrate.** It
|
|
||||||
delegates HLS to FFmpeg's demuxer, which picks one variant at open and never
|
|
||||||
adapts. The webview path has real ABR via hls.js. Compositing is necessary for
|
|
||||||
native video on Linux; it is not sufficient.
|
|
||||||
|
|
||||||
There is a plausible answer, and this spike exists partly to make it testable:
|
|
||||||
**ABR only matters on the transcode path.** A direct-played file has no variant
|
|
||||||
ladder to adapt between — the adaptation the server offers *is* the transcode.
|
|
||||||
So "mpv when the stream is direct-play, HTML5 + hls.js when the server
|
|
||||||
transcodes" would sidestep finding 3 rather than fight it, and it maps onto a
|
|
||||||
decision Rust already makes when it builds the stream URL.
|
|
||||||
|
|
||||||
That is a **hypothesis, not a conclusion.** It is out of scope here. Record it in
|
|
||||||
the spike's decision note so the follow-up spec starts from it.
|
|
||||||
|
|
||||||
## Layer assignment
|
|
||||||
|
|
||||||
The spike introduces no product logic. The table below is the assignment the
|
|
||||||
*follow-up* would inherit, written now so a green result cannot drift into
|
|
||||||
frontend decisions during implementation.
|
|
||||||
|
|
||||||
| Logic / responsibility | Layer | Why it belongs there |
|
|
||||||
|------------------------|-------|----------------------|
|
|
||||||
| Which backend renders video on this platform (`use_html5_element`, `supports_native_video`) | Rust | Already there — `get_player_status` in `commands/player/mod.rs` computes it from a `cfg!`. The spike would widen that `cfg!`, not relocate the decision. The frontend already consumes it via `createAdapter`. |
|
|
||||||
| Whether *this stream* is direct-play or transcoded, and therefore whether mpv or hls.js renders it | Rust | Domain. It depends on Jellyfin's `PlaybackInfo` response, container/codec support, and the bitrate cap — all of which change when Jellyfin's API or our quality ladder changes. The frontend must never re-derive it from a URL shape. |
|
|
||||||
| Creating, sizing, and destroying the GL surface; the mpv render context | Rust | Owns the backend and the GTK window handle. There is no presentation decision in it. |
|
|
||||||
| Where controls, subtitles, and the mini-player sit above the video, and the letterbox/poster treatment | Frontend | Pure presentation; changes only if the UI is redesigned. Precisely the split the Android path already uses. |
|
|
||||||
| Reserving the video rectangle in layout and marking the shell transparent | Frontend | Presentation. `nativeVideo.ts` + the `[data-native-video="active"]` rule in `app.css` already do this for Android and are platform-agnostic. |
|
|
||||||
|
|
||||||
Borderline row, stated with its tie-breaker: *"is the surface currently
|
|
||||||
attached?"* reads like view state, but the Android work found that a surface left
|
|
||||||
in the hierarchy outlives its player (DR-184). Attachment is backend lifecycle →
|
|
||||||
**Rust**, with the frontend told about it, not asked.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
A throwaway branch. No merge to `master` except the decision note.
|
|
||||||
|
|
||||||
### What gets built
|
|
||||||
|
|
||||||
One `#[cfg(target_os = "linux")]` experiment behind a feature flag, in a scratch
|
|
||||||
binary or an ignored test — **not** in `MpvBackend`'s constructor path:
|
|
||||||
|
|
||||||
1. From `app.get_webview_window(...)`, take `gtk_window()` and `default_vbox()`.
|
|
||||||
2. Reparent the webview into a `gtk::Overlay`: `GLArea` as the main child, the
|
|
||||||
webview as the overlay child.
|
|
||||||
3. Set the webview background to fully transparent (wry does this when
|
|
||||||
`"transparent": true`; verify it reaches `webkit_web_view_set_background_color`).
|
|
||||||
4. In the `GLArea`'s `render` signal, drive
|
|
||||||
`mpv_render_context_render` with `MPV_RENDER_PARAM_OPENGL_FBO` pointing at the
|
|
||||||
FBO GTK bound for us.
|
|
||||||
5. Play one local file. Draw an opaque HTML element over the video area.
|
|
||||||
|
|
||||||
`video = no` and `audio-display = no` are set in
|
|
||||||
[mpv_backend.rs:135-141](../../src-tauri/src/player/mpv_backend.rs#L135-L141);
|
|
||||||
the spike overrides them on its own `Mpv` handle rather than editing that path.
|
|
||||||
|
|
||||||
### Bindings
|
|
||||||
|
|
||||||
The current pin is `libmpv = { git = "…/libmpv-rs", branch = "master" }` — the
|
|
||||||
dead pin [libmpv2-migration.md](libmpv2-migration.md) exists to replace. The
|
|
||||||
render API lives in `libmpv2-sys` (`mpv_render_context_render`); the safe wrapper
|
|
||||||
was only ever a PR against the old crate. **Use `libmpv2-sys` raw FFI directly in
|
|
||||||
the spike.** Do not block the spike on the migration, and do not let the spike
|
|
||||||
half-perform it — if the spike goes green the migration becomes a hard
|
|
||||||
prerequisite of the implementation, which is the ordering
|
|
||||||
[windows-native-audio-backend.md](windows-native-audio-backend.md) already sits
|
|
||||||
in.
|
|
||||||
|
|
||||||
### IPC
|
|
||||||
|
|
||||||
None. The spike crosses no boundary. If it goes green, the follow-up changes only
|
|
||||||
the *value* of the existing `useHtml5Element` / `supportsNativeVideo` fields — no
|
|
||||||
new wire shapes, no `bindings.ts` regeneration.
|
|
||||||
|
|
||||||
## Gates
|
|
||||||
|
|
||||||
Each is pass/fail with a named failure. Stop at the first red and write it up —
|
|
||||||
a red result is a successful spike.
|
|
||||||
|
|
||||||
| # | Question | Fails if |
|
|
||||||
|---|---|---|
|
|
||||||
| G1 | Can a custom GTK widget join Tauri's widget tree and survive the window's lifetime? | `default_vbox()` is absent/unusable, or reparenting the webview breaks input or crashes. |
|
|
||||||
| G2 | Does the webview still paint, with a transparent backdrop, over that widget? | The backdrop renders opaque black ([wry#1540](https://github.com/tauri-apps/wry/issues/1540)) or the webview stops repainting ([tauri#12800](https://github.com/tauri-apps/tauri/issues/12800)). **This is the highest-risk gate.** |
|
|
||||||
| G3 | Does mpv render a frame into our FBO? | The render context refuses GTK's context, or frames land in the wrong buffer. |
|
|
||||||
| G4 | Does HTML drawn over the video area actually appear over it? | Video covers the controls — the exact failure wry#284 and tauri#6343 report. Without this, the whole thing is worthless: our controls, subtitles and mini-player all sit over the video. |
|
|
||||||
| G5 | Does it survive resize, fullscreen, and SPA navigation away and back? | Flicker on resize, or a surface that outlives its route. |
|
|
||||||
| G6 | Does it hold on **both** X11 and Wayland? | Either session backend fails. Wayland is the one the 2024 maintainer quote says is impossible — test it first, not last. |
|
|
||||||
|
|
||||||
G6 is not a nice-to-have. A result that only holds on X11 is red for a project
|
|
||||||
shipping to current desktops.
|
|
||||||
|
|
||||||
### Time box
|
|
||||||
|
|
||||||
If G1–G4 are not all green, stop and write the result up. The value of this spike
|
|
||||||
is a dated, method-specific answer — including "still no, and here is the
|
|
||||||
mechanism" — not a working player.
|
|
||||||
|
|
||||||
## Result (2026-08-21)
|
|
||||||
|
|
||||||
Run on GNOME, kernel 7.1.8, libmpv 2.5.0 (mpv 0.41.0), GTK 3.24.52, WebKitGTK
|
|
||||||
2.52.6, wry 0.53.5 — the versions `src-tauri/Cargo.lock` resolves. Spike source:
|
|
||||||
a ~250-line standalone crate using wry + gtk + `libmpv2-sys` raw FFI, driving
|
|
||||||
mpv's render API with an update callback, frame-gated repaints and
|
|
||||||
`report_swap`.
|
|
||||||
|
|
||||||
| Gate | Result | Observed mechanism |
|
|
||||||
|---|---|---|
|
|
||||||
| G1 widget in GTK tree | 🟡 **partial** | `GtkOverlay` with `GtkGLArea` as main child and the wry webview as overlay child works, built directly. **Tauri's own `default_vbox()` was not exercised** — see below. |
|
|
||||||
| G2 webview paints transparently over it | ✅ green | `with_transparent(true)` alone. No window-level transparency was used or needed. |
|
|
||||||
| G3 mpv renders into our FBO | ✅ green | `vo=libmpv` + `mpv_render_context_create` with `MPV_RENDER_PARAM_OPENGL_FBO` into the FBO GTK binds. |
|
|
||||||
| G4 HTML over video | ✅ green | Opaque panel and a translucent control bar both drew over moving video. |
|
|
||||||
| G5 resize / drag / fullscreen | 🟡 **green on appearance, suspect underneath** | No flicker, gap or misalignment, and smooth once frame pacing was correct (trap 3). But the only crash observed came from the only session where fullscreen was exercised — see "What is still open". |
|
|
||||||
| G6 X11 **and** Wayland | ✅ green | Identical on both; `GDK_BACKEND` flipped between runs. |
|
|
||||||
|
|
||||||
**Finding 2 of [playback-backend-unification.md](playback-backend-unification.md)
|
|
||||||
is false on Linux** when tested by the render API rather than by foreign-window
|
|
||||||
embedding. Wayland — the half the 2024 maintainer quote called impossible — is
|
|
||||||
green.
|
|
||||||
|
|
||||||
Better than the gate asked for: the translucent bar composited *alpha* against
|
|
||||||
the video, not merely opaque-over. Scrims, gradient fades and subtitle backdrops
|
|
||||||
therefore work, which is most of how a player UI actually looks. mpv also painted
|
|
||||||
the letterbox bars black on its own — the Android equivalent was a shipped defect
|
|
||||||
(DR-194).
|
|
||||||
|
|
||||||
### Three traps, each of which cost a debugging cycle
|
|
||||||
|
|
||||||
Carry these into the implementation; each produced a failure that looked like a
|
|
||||||
platform limitation and was not.
|
|
||||||
|
|
||||||
1. **`LC_NUMERIC` must be reset *after* `gtk::init()`, not before.** mpv refuses
|
|
||||||
to start under a non-C numeric locale. `mpv_backend.rs` already handles this,
|
|
||||||
but it has no GTK init in front of it; on this path `gtk::init()` applies the
|
|
||||||
user's locale afterwards and `mpv_create` returns null.
|
|
||||||
2. **libepoxy exports GL entry points as *data* symbols.** There is no `glFoo`
|
|
||||||
function to resolve — there is `epoxy_glFoo`, a variable holding a lazily
|
|
||||||
resolving function pointer. `get_proc_address` must return the pointer *stored
|
|
||||||
at* that symbol; returning the symbol's own address makes mpv jump into
|
|
||||||
non-executable data and take SIGSEGV/SEGV_ACCERR on the first GL call. The
|
|
||||||
`epoxy` crate does this correctly but is unusable — its `gl_generator`
|
|
||||||
dependency pulls a yanked `xml-rs`.
|
|
||||||
3. **Frame pacing is not optional, and its symptom is misleading.** Driving
|
|
||||||
`queue_render()` off the widget's frame clock on every tick, without calling
|
|
||||||
`mpv_render_context_report_swap` after each render, leaves mpv with nothing to
|
|
||||||
time against. Playback looks fine in a window and **judders at fullscreen** —
|
|
||||||
which reads as a compositing or GPU limit and is neither. The fix is to
|
|
||||||
register `mpv_render_context_set_update_callback`, redraw only when it says a
|
|
||||||
frame is ready, and report the swap afterwards. Fullscreen was smooth
|
|
||||||
immediately once both were in place.
|
|
||||||
|
|
||||||
### Hardware decode through the render API
|
|
||||||
|
|
||||||
Tested by asking mpv what it actually selected (`hwdec-current`), not what it was
|
|
||||||
asked for. All three ran 20s clean at a steady 30 fps.
|
|
||||||
|
|
||||||
| `hwdec` | `hwdec-current` | Note |
|
|
||||||
|---|---|---|
|
|
||||||
| `vaapi` | `no` | **Did not engage** on this box — silently fell back to software. `vainfo` is not installed, so the libva driver for the Iris Xe iGPU is likely absent. No render-API error; this looks like a missing driver package, not a compositing limit. |
|
|
||||||
| `auto` | `nvdec-copy` | Hardware decode **does** work through the render API, on the discrete RTX 3050. Copy-back rather than zero-copy interop. |
|
|
||||||
| `no` | `no` | Software. Clean baseline. |
|
|
||||||
|
|
||||||
The load-bearing result is the middle row: **hardware decode is compatible with
|
|
||||||
mpv's render API**, so the direct-play prize is real and not traded away for
|
|
||||||
software decoding. Which decoder to prefer is an implementation question — on a
|
|
||||||
hybrid Intel+NVIDIA laptop `auto` reached for the discrete GPU in copy-back mode,
|
|
||||||
which is the least efficient hardware path. An implementation should evaluate
|
|
||||||
zero-copy VA-API on the iGPU (after confirming the driver is installed) before
|
|
||||||
accepting `auto`.
|
|
||||||
|
|
||||||
`hwdec=auto-safe` probes Vulkan video decode, which this GPU does not support.
|
|
||||||
It logs two `Failed setup for format vulkan` / `no frame!` pairs at start-up and
|
|
||||||
then settles on `nvdec-copy` — the same place `auto` lands. A first reading of
|
|
||||||
these logs mistook the start-up pair for a per-frame flood; **it is not**. Every
|
|
||||||
run, clean or crashed, contains exactly two. `auto-safe` is not implicated in
|
|
||||||
anything.
|
|
||||||
|
|
||||||
### What is still open
|
|
||||||
|
|
||||||
- **The Tauri half of G1.** The spike built its own `GtkOverlay`. The app must
|
|
||||||
instead reach `WebviewWindow::gtk_window()` / `default_vbox()` and reparent
|
|
||||||
Tauri's existing webview into an overlay. Low risk — the same widgets, one
|
|
||||||
extra reparent — but unproven, and it is the only place Tauri-specific
|
|
||||||
behaviour could still bite.
|
|
||||||
- ✅ **ABR — resolved. Finding 3's premise is false.** Finding 3 said mpv would
|
|
||||||
regress streaming quality because "the webview path already has real ABR via
|
|
||||||
hls.js". Three pieces of evidence in this repo suggested that is **not true of
|
|
||||||
the URLs we actually build**:
|
|
||||||
|
|
||||||
1. `get_video_stream_url` (`repository/online.rs`) requests a *single*
|
|
||||||
rendition — one `VideoBitrate`, one `MaxStreamingBitrate`, one `MaxHeight`.
|
|
||||||
Jellyfin transcodes to what it is asked for; it does not build a ladder.
|
|
||||||
2. The frontend contains **no level-handling code at all** — no `hls.levels`,
|
|
||||||
no `LEVEL_SWITCH`, no `currentLevel`. The `abrEwma*` options in
|
|
||||||
`VideoPlayer.svelte` are default tuning with nothing to act on. hls.js is
|
|
||||||
serving as an HLS *demuxer* (WebKitGTK cannot play HLS natively), not as an
|
|
||||||
adaptation engine.
|
|
||||||
3. That function's own comment describes a quality switch as **rebuilding the
|
|
||||||
URL** — "every path that re-opens a stream (quality switch, transcoded seek,
|
|
||||||
audio-track switch)". Manual selection by stream re-open is what you build
|
|
||||||
when there is no adaptation, and mpv can do the same thing.
|
|
||||||
|
|
||||||
**The decisive test has now been run** (2026-08-21, against the development
|
|
||||||
server, Jellyfin 10.11.5):
|
|
||||||
|
|
||||||
```
|
|
||||||
curl -s ".../Videos/<itemId>/master.m3u8?…&TranscodingProtocol=hls&…" \
|
|
||||||
| grep -c EXT-X-STREAM-INF
|
|
||||||
1
|
|
||||||
```
|
|
||||||
|
|
||||||
**One line.** The playlist carries a single `EXT-X-STREAM-INF` plus an
|
|
||||||
`EXT-X-IMAGE-STREAM-INF` trickplay entry, which is not a rendition. Jellyfin
|
|
||||||
builds the master playlist from the rendition the request asked for; it does
|
|
||||||
not publish a ladder. So **there is no ABR to lose, and this blocker is
|
|
||||||
closed** — hls.js is serving as an HLS demuxer, exactly as (2) above supposed,
|
|
||||||
and mpv gives up nothing by replacing it.
|
|
||||||
|
|
||||||
Recorded as DR-229 (Won't Do) rather than deleted, because it is a
|
|
||||||
measurement: a server that *does* publish a ladder would change the answer, and
|
|
||||||
the re-negotiation path is the hook that work would build on.
|
|
||||||
|
|
||||||
**The direct-play path now exists.** It did not when this spike was written —
|
|
||||||
every video play went through the HLS transcode endpoint. Backend-owned stream
|
|
||||||
selection (DR-225 … DR-230) built it: Rust negotiates direct play / direct
|
|
||||||
stream / transcode and hands every backend one `StreamSelection` carrying the
|
|
||||||
URL, the transport and the chosen rendition. **That is the contract this
|
|
||||||
implementation consumes** — mpv is a consumer of a decision already made, not a
|
|
||||||
place to re-derive it.
|
|
||||||
|
|
||||||
It also sizes the prize precisely. Measured over the same server, 40 items
|
|
||||||
through a real negotiation per profile:
|
|
||||||
|
|
||||||
| Profile | Direct play |
|
|
||||||
|---|---|
|
|
||||||
| Linux / WebKitGTK — `h264` only, 2ch | **7%** |
|
|
||||||
| Android / ExoPlayer — `h264,hevc,vp8,vp9,av1,mpeg4` + `ac3,eac3`, 6ch | **85%** |
|
|
||||||
|
|
||||||
**The 85% is a ceiling, not a shipped result** — it was measured with a
|
|
||||||
profile containing `ac3,eac3`, which the Android device later used for
|
|
||||||
verification does not support.
|
|
||||||
|
|
||||||
The library sampled is ~80% hevc. Linux sits at 7% **solely because the
|
|
||||||
WebKitGTK profile can only claim h264** — not because of anything about the
|
|
||||||
server or the negotiation. mpv decodes hevc, so widening the Linux device
|
|
||||||
profile once mpv renders the picture is what converts that 7% toward the
|
|
||||||
Android figure. That conversion is the actual product of this work; the
|
|
||||||
compositing proven above is the mechanism that permits it.
|
|
||||||
- 🔴 **One unexplained SIGSEGV.** A ~180s
|
|
||||||
run died in a *decoder* thread (libavcodec -> `av_log` -> libmpv's log handler
|
|
||||||
-> libc). No Tauri, wry, WebKitGTK, GTK or GL frame appears anywhere in the
|
|
||||||
stack, so the fault is on the mpv/ffmpeg side of the process rather than in the
|
|
||||||
compositing seam.
|
|
||||||
|
|
||||||
Three hypotheses were tested and **none reproduced it**:
|
|
||||||
|
|
||||||
| Hypothesis | Test | Result |
|
|
||||||
|---|---|---|
|
|
||||||
| `hwdec=auto-safe`'s Vulkan failures | 300s soak on `auto-safe` | Survived. Also based on a misreading — the failures are 2 per run at start-up, not per-frame. Dead. |
|
|
||||||
| Fullscreen transitions recreating the GL context under mpv's render context | 240s soak, ~120 automated transitions | Survived, no core dumped. |
|
|
||||||
| Continuous resize thrashing the GL framebuffer | 240s soak, ~2000 resizes | Survived, no core dumped. |
|
|
||||||
|
|
||||||
**The crash is therefore unexplained.** It was observed exactly once, in the
|
|
||||||
only session a human interacted with, and did not recur in ~13 minutes of
|
|
||||||
targeted stress across the three most plausible causes. It is recorded here
|
|
||||||
rather than dismissed precisely because nothing explains it: an intermittent
|
|
||||||
fault that nobody can reproduce is worse to inherit than a deterministic one,
|
|
||||||
not better.
|
|
||||||
|
|
||||||
The underlying concern stands regardless of which test eventually reproduces
|
|
||||||
it. A SIGSEGV in an unrelated thread is characteristic of memory corruption,
|
|
||||||
and this spike never calls `mpv_render_context_free` and never tears down on
|
|
||||||
`unrealize` — it has no defence against the GL context being recreated beneath
|
|
||||||
the render context. That is DR-184 on Android restated: a surface outliving its
|
|
||||||
player. An implementation must bind the two lifetimes together whether or not
|
|
||||||
this particular crash is ever explained.
|
|
||||||
|
|
||||||
**Therefore G5 is recorded green on appearance only**, and this crash is the
|
|
||||||
single largest piece of unfinished business in the spike. Do not read the green
|
|
||||||
gates above as "safe to build on" until it is explained or a long soak clears
|
|
||||||
it.
|
|
||||||
- Long-run stability, seeking, track switching, HDR, and multi-window were not
|
|
||||||
exercised at all.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- Any change to the shipping Linux video path. `experimentalNativeVideo` in
|
|
||||||
`adapters/index.ts` is a **suppressor, never a promoter**; the spike must not
|
|
||||||
change that.
|
|
||||||
- Adaptive bitrate. See "The blocker a green spike does not clear".
|
|
||||||
- Windows and macOS — different mechanisms, and **Windows is the easier case, not
|
|
||||||
the endangered one**. See below.
|
|
||||||
- Android. Already shipped; it is the precedent, not the target.
|
|
||||||
- Crossfade, the libmpv2 migration, and the audio-parity work.
|
|
||||||
|
|
||||||
### Why Windows is unaffected, and cheaper
|
|
||||||
|
|
||||||
Nothing here can regress Windows. `use_html5_element` is already a per-platform
|
|
||||||
`cfg!` in `get_player_status` — Android native, everything else HTML5 — so
|
|
||||||
divergent video paths are the existing design rather than something this
|
|
||||||
introduces. Windows keeps `<video>` + hls.js whatever this spike returns.
|
|
||||||
|
|
||||||
The mechanism does not port: `default_vbox()`, `GtkOverlay` and `GtkGLArea` are
|
|
||||||
GTK3/WebKitGTK concepts. But the *question* is already answered more favourably
|
|
||||||
there. Both mpv plugins list Windows as **fully tested** and Linux as broken,
|
|
||||||
because WebView2 honours a transparent background — the "native surface beneath a
|
|
||||||
transparent webview" approach that fails on WebKitGTK is the one that works on
|
|
||||||
Windows. That asymmetry is why
|
|
||||||
[windows-native-audio-backend.md](windows-native-audio-backend.md) can call
|
|
||||||
Windows "the cleanest available win".
|
|
||||||
|
|
||||||
Windows' cost is packaging, not compositing: the build cross-compiles with MSVC +
|
|
||||||
`cargo-xwin`, so libmpv arrives as a bundled prebuilt DLL (the ⚠️ in finding 5's
|
|
||||||
comparison table). That cost is already committed for *audio*. Once the DLL ships
|
|
||||||
to replace `WebviewAudioBackend`, Windows video is largely a follow-on.
|
|
||||||
|
|
||||||
Sequencing, if native video is ever pursued on both:
|
|
||||||
|
|
||||||
1. [libmpv2-migration.md](libmpv2-migration.md) — prerequisite for either.
|
|
||||||
2. [windows-native-audio-backend.md](windows-native-audio-backend.md) — already
|
|
||||||
specced; lands the DLL and a real Windows backend.
|
|
||||||
3. Windows native video — cheap once 2 exists, and does not need this spike.
|
|
||||||
4. Linux native video — needs this spike, and runs independently of 1–3.
|
|
||||||
|
|
||||||
### Does this add a backend?
|
|
||||||
|
|
||||||
No — and the trajectory is convergence, not proliferation.
|
|
||||||
|
|
||||||
`create_player_backend` in `lib.rs` already selects between four
|
|
||||||
`PlayerBackend` impls by `cfg!`: `MpvBackend` (Linux), `ExoPlayerBackend`
|
|
||||||
(Android), `WebviewAudioBackend` (Windows and anything else), and `NullBackend`
|
|
||||||
as the graceful-init fallback. The HTML5 video path is not among them — it is a
|
|
||||||
frontend adapter reporting through `player_report_*`, not a `PlayerBackend`.
|
|
||||||
|
|
||||||
This spike adds none of these. `MpvBackend` already exists and already runs on
|
|
||||||
Linux; it merely sets `video = no` at construction. Giving it video widens an
|
|
||||||
existing backend rather than introducing an engine.
|
|
||||||
|
|
||||||
Following the sequence above, the count goes **down**: replacing
|
|
||||||
`WebviewAudioBackend` with mpv on Windows leaves two native engines — mpv
|
|
||||||
(Linux + Windows) and ExoPlayer (Android) — with native video riding on both.
|
|
||||||
|
|
||||||
Two is the floor, for a reason worth stating so nobody re-litigates it: Android
|
|
||||||
cannot drop ExoPlayer even if libmpv runs there, because the foreground service,
|
|
||||||
`MediaSessionCompat` and lockscreen control are built on it (finding 7 puts the
|
|
||||||
cost at that rewrite, not at the bindings). The HTML5 path does not go away
|
|
||||||
either — it is the transcode/ABR route and the fallback.
|
|
||||||
|
|
||||||
The trait surface converges too: `ExoPlayerBackend` already implements the
|
|
||||||
video-surface lifecycle for Android native compositing, so teaching `MpvBackend`
|
|
||||||
video follows a path already walked rather than opening a second one.
|
|
||||||
- Adopting `tauri-plugin-libmpv` or `tauri-plugin-mpv` as dependencies. Both
|
|
||||||
report Linux window embedding as not working and are small projects
|
|
||||||
(20 and ~70 commits); read them, do not depend on them.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
The deliverable is a decision, not a feature.
|
|
||||||
|
|
||||||
- [ ] Each of G1–G6 recorded green/red **with the observed mechanism**, not just
|
|
||||||
the verdict.
|
|
||||||
- [ ] X11 and Wayland results reported separately, each naming the compositor
|
|
||||||
and WebKitGTK version tested.
|
|
||||||
- [ ] The direct-play/transcode ABR hypothesis recorded as open, with whatever
|
|
||||||
the spike learned about it.
|
|
||||||
- [ ] `docs/specs/README.md` updated — this spec listed, and its row moved or
|
|
||||||
deleted per the result.
|
|
||||||
- [ ] The Linux claim in the `createAdapter` doc comment
|
|
||||||
([adapters/index.ts:12-13](../../src/lib/player/adapters/index.ts#L12-L13))
|
|
||||||
corrected either way: if red, cite this spike instead of asserting it; if
|
|
||||||
green, it is wrong and must be rewritten.
|
|
||||||
- [ ] On **red**: finding 2 of
|
|
||||||
[playback-backend-unification.md](playback-backend-unification.md) gains a
|
|
||||||
dated note naming the render-API method as also tested, and this file is
|
|
||||||
deleted. The verdict lives in the design-authority spec, not in a second
|
|
||||||
file that contradicts nothing.
|
|
||||||
- [ ] On **green**: an implementation spec exists, allocating ids from
|
|
||||||
**UR-077 / IR-033 / DR-216** (re-check `requirements.md` — the README's
|
|
||||||
"next free DR-215" is stale, DR-215 landed), and it must answer ABR before
|
|
||||||
being accepted.
|
|
||||||
- [ ] No spike code on `master`. If any lands, the standard gates apply:
|
|
||||||
`bun run check`, `bun run test`, `bun run check:boundary`, `cargo fmt`,
|
|
||||||
`cargo clippy`, `bun run test:rust`.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
No automated tests. A compositing result is a visual, per-session-backend
|
|
||||||
observation and cannot be asserted in `cargo test` or vitest — pretending
|
|
||||||
otherwise would produce a test that passes on a headless runner and tells us
|
|
||||||
nothing.
|
|
||||||
|
|
||||||
Capture a screenshot per gate. G4 specifically: an opaque HTML element over the
|
|
||||||
video area, photographed showing the video *behind* it.
|
|
||||||
|
|
||||||
If it goes green, the implementation spec inherits the testable surface the
|
|
||||||
Android work already established — `nativeVideoLayers.test.ts` asserts the
|
|
||||||
`app.css` selector list and the `data-native-video` contract, and both are
|
|
||||||
platform-agnostic.
|
|
||||||
|
|
||||||
## TRACES
|
|
||||||
|
|
||||||
None. No requirement-implementing code is produced. The implementation spec that
|
|
||||||
follows a green result allocates from DR-216 and tags there.
|
|
||||||
|
|
||||||
## Notes for the implementer
|
|
||||||
|
|
||||||
- **Read [playback-backend-unification.md](playback-backend-unification.md)
|
|
||||||
first, in full.** This spike disputes exactly one of its six findings, on one
|
|
||||||
platform, by one method it did not try. Everything else in it is still binding
|
|
||||||
— particularly finding 3.
|
|
||||||
- Test **Wayland first**. It is the gate most likely to be red and the one that
|
|
||||||
makes the rest moot.
|
|
||||||
- The frontend plumbing already exists from the Android work: `createAdapter`,
|
|
||||||
`NativePlayerAdapter`, `nativeVideo.ts`, `videoSurface.ts`, and the
|
|
||||||
`[data-native-video="active"]` rule. A green spike is far cheaper to implement
|
|
||||||
than it would have been a year ago — which is itself part of why the question
|
|
||||||
is worth re-asking.
|
|
||||||
- The Android record in
|
|
||||||
[05-platform-backends.md](../architecture/05-platform-backends.md#native-video-compositing-android)
|
|
||||||
lists six shipped defects from getting this right on one platform. Expect the
|
|
||||||
Linux equivalents (the surface outliving its player, the shell painting over
|
|
||||||
it, unpainted letterbox bars) rather than rediscovering them.
|
|
||||||
- A parallel Claude session may be active in this repo — `git diff` before
|
|
||||||
"repairing" unexpected changes.
|
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
# Spec: MediaPlayer — one controller API, three interchangeable engines
|
|
||||||
|
|
||||||
**Status:** **Partially implemented.** DR-242 … DR-247 have shipped: the
|
|
||||||
contract, `FakePlayer` and the conformance suite, `MpvPlayer`, the standalone
|
|
||||||
runner, `LegacyPlayer`, the controller port, the capability-driven seek
|
|
||||||
strategy, and ExoPlayer conformance on a device. What is left is DR-248 (the
|
|
||||||
webview as an engine) and DR-249 (deleting `PlayerBackend` and the frontend
|
|
||||||
playback-state flags).
|
|
||||||
**Requirements:** UR-081 (new) → DR-242 … DR-249 (new); IR-034. Re-check
|
|
||||||
`requirements.md` before allocating — ids moved several times while this was
|
|
||||||
written.
|
|
||||||
**UX spec:** n/a — no user-visible change is intended. That is the point.
|
|
||||||
**Supersedes / revises:** absorbs `determine_video_seek_strategy`
|
|
||||||
(`player/seek.rs`, DR-238) into the engines. Revises the backend half of
|
|
||||||
[playback-backend-unification.md](playback-backend-unification.md).
|
|
||||||
|
|
||||||
**Destination on completion:**
|
|
||||||
[01-rust-backend.md](../architecture/01-rust-backend.md) — replaces the player
|
|
||||||
state-machine section; and
|
|
||||||
[05-platform-backends.md](../architecture/05-platform-backends.md) — the engines
|
|
||||||
become implementations of a stated contract rather than three separate designs.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Replace the `PlayerBackend` trait with a `MediaPlayer` contract that expresses
|
|
||||||
**intent** ("present this item, starting here") rather than **device operations**
|
|
||||||
("load", then "seek"). MPV, ExoPlayer and the webview element implement it; a
|
|
||||||
`FakePlayer` implements it for tests; and one conformance suite runs against
|
|
||||||
every implementation so a backend is either correct or visibly failing.
|
|
||||||
|
|
||||||
No user-visible behaviour changes. What changes is that playback logic stops
|
|
||||||
being written three times in the command layer.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
A day of debugging Linux native video produced four defects (DR-238 … DR-241).
|
|
||||||
Every one of them traces to the same missing seam, not to mpv:
|
|
||||||
|
|
||||||
| Defect | What it looked like | What it was |
|
|
||||||
|---|---|---|
|
|
||||||
| DR-241 | "Resume is broken", "I cannot skip" | `loadfile` is async, so a seek issued straight after a load fails and was discarded. The trait has no way to say *open at a position*, so every caller does load-then-seek and each races independently. |
|
|
||||||
| DR-238 | Transcoded seeks silently did nothing | `use_html5` was doing double duty as "who renders" **and** "how do I seek", decided in the command layer by a truth table. |
|
|
||||||
| DR-239 | Play/pause control never moved | `PropertyChange { name: "pause" }` was handled but never observed. Nothing in the contract required an engine to report its own state. |
|
|
||||||
| DR-240 | Fullscreen left the picture at window size | `requestFullscreen()` moves the document; whoever owns the pixels has to be told separately. |
|
|
||||||
|
|
||||||
The shape is consistent: **the same intent implemented in several places, each
|
|
||||||
with its own timing and its own idea of the rules.** Resume worked through the
|
|
||||||
adapter (which seeks after `File loaded`) and failed through the command (which
|
|
||||||
seeks immediately). Two callers, one intent, two behaviours.
|
|
||||||
|
|
||||||
Supporting evidence for the diagnosis:
|
|
||||||
|
|
||||||
- `commands/player/mod.rs` is **3,561 lines** and is where "stop → rebuild URL →
|
|
||||||
update queue → load → seek" lives. That is playback orchestration in the IPC
|
|
||||||
layer.
|
|
||||||
- `player_play_item` needed a `#[cfg(not(target_os = "linux"))]` guard, i.e. a
|
|
||||||
platform decision in a command handler.
|
|
||||||
- The frontend carries `didStartNativePlayback`, `didStopBackendEarly`,
|
|
||||||
`hasPerformedInitialSeek`, `lastAppliedInitialPosition` — playback state in the
|
|
||||||
UI, which contradicts the one-directional rule in CLAUDE.md.
|
|
||||||
|
|
||||||
### Why an abstraction, and not more fixes
|
|
||||||
|
|
||||||
Each defect above was individually cheap to patch, and patching them is what
|
|
||||||
produced a regression: routing transcoded seeks to a reload path turned "seek
|
|
||||||
does nothing" into "seek jumps to zero", because the reload path's own seek was
|
|
||||||
broken in the same way. **Symptom fixes in this area compound.**
|
|
||||||
|
|
||||||
## The background-audio handoff is an unconfirmed state swap
|
|
||||||
|
|
||||||
Diagnosed on a device, 2026-08-23, and the likeliest explanation for "audio
|
|
||||||
keeps playing after I leave the player" — the report this whole line of work
|
|
||||||
started from.
|
|
||||||
|
|
||||||
`enter_background_audio` and `exit_background_audio` in `PlayerController` are
|
|
||||||
pure bookkeeping: they flip a boolean and set or clear a base offset. Neither
|
|
||||||
confirms that the audio stream actually opened, nor that the webview `<video>`
|
|
||||||
actually came back. `exit_background_audio`'s own doc comment says the element
|
|
||||||
"becomes the player again once it reloads" — a future event nothing waits for,
|
|
||||||
while the flag declares the swap complete the moment it is called.
|
|
||||||
|
|
||||||
The sequence that exposes it:
|
|
||||||
|
|
||||||
1. Background audio is enabled.
|
|
||||||
2. The app is backgrounded — `enter_background_audio(pos)`, audio stream opens.
|
|
||||||
3. The app is foregrounded — `exit_background_audio()` sets the flag back, so
|
|
||||||
the controller believes the video element owns playback again.
|
|
||||||
4. The player is exited *before the element has reloaded*. The stop is aimed at
|
|
||||||
an element that does not exist yet; the audio stream is still running.
|
|
||||||
5. The mini player sees a live audio session and adopts it — which is why the
|
|
||||||
symptom is a **movie appearing as an audio track**, and why it is
|
|
||||||
intermittent rather than reliable.
|
|
||||||
|
|
||||||
Duration reporting `0.0` on Android widens the window: the reload is slower and
|
|
||||||
less certain to land at the right position.
|
|
||||||
|
|
||||||
**This is the same defect class as DR-238 … DR-241: state asserted rather than
|
|
||||||
confirmed.** It is what `Phase::Opening` and `MpvPlayer`'s open generation
|
|
||||||
exist for — a handoff *is* an open in flight, and a `close` during one has to
|
|
||||||
cancel it rather than race it. The handoff is not modelled as an open at all
|
|
||||||
today; it is two booleans and an offset.
|
|
||||||
|
|
||||||
The fix therefore belongs with this contract rather than beside it: route the
|
|
||||||
handoff through `open`/`close` so the swap has a phase, and so leaving the
|
|
||||||
player during one cancels the thing that is actually playing instead of the
|
|
||||||
thing the controller believes is playing. `close_during_open_never_plays`
|
|
||||||
already states the required behaviour and passes on all four engines — the gap
|
|
||||||
is that the handoff never reaches an engine as an open.
|
|
||||||
|
|
||||||
## Layer assignment
|
|
||||||
|
|
||||||
| Logic / responsibility | Layer | Why it belongs there |
|
|
||||||
|---|---|---|
|
|
||||||
| Presenting an item at a position, in one operation | **Engine** (`MediaPlayer`) | Only the engine knows when its pipeline can accept a position. Expressing it as caller-sequenced load-then-seek exports a race the engine is the only one able to close. |
|
|
||||||
| Whether *this* stream can be seeked in place, or must be re-opened | **Engine** | A property of the engine × transport pair: hls.js seeks a VOD playlist, mpv's HLS demuxer cannot make Jellyfin transcode from a new offset. Today this is a truth table in a command handler that has to guess for engines it does not own. |
|
|
||||||
| Reporting position, phase, duration, active tracks | **Engine** | The player is the authoritative source of playback state (CLAUDE.md). An engine that does not report is not implementing the contract — DR-239 was exactly this. |
|
|
||||||
| Choosing *which* stream to open (direct play vs transcode, ceiling, transport) | **Rust, above the engine** | Domain: depends on Jellyfin's `PlaybackInfo`, codec support, quality ceiling. See [backend-owned-stream-selection.md](backend-owned-stream-selection.md). The engine is handed a `StreamSelection`; it never negotiates one. |
|
|
||||||
| Queue, autoplay, session, playback reporting | **`PlayerController`** | Policy across items. Unchanged — but it talks to one contract instead of branching per platform. |
|
|
||||||
| Which engine this platform uses | **Rust, at construction** | Already correct today; stays a single `cfg` at the composition root rather than `cfg`s scattered through command handlers. |
|
|
||||||
| Rendering surfaces, controls, fullscreen chrome | **Frontend / platform** | Presentation. The engine reports *what* is playing; it does not own the window. |
|
|
||||||
|
|
||||||
Borderline row and its tie-breaker: "should a transcoded seek re-open the
|
|
||||||
stream?" reads like domain policy. It is **engine** capability — the *decision*
|
|
||||||
is "seek to T", and how to achieve it is the engine's business. If it were
|
|
||||||
policy, every new engine would require editing a shared truth table, which is
|
|
||||||
precisely the coupling DR-238 came from.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### The contract
|
|
||||||
|
|
||||||
```rust
|
|
||||||
/// Anything that can present media: MpvPlayer, ExoPlayer, WebviewPlayer, FakePlayer.
|
|
||||||
pub trait MediaPlayer: Send {
|
|
||||||
/// Present `req.selection`, beginning at `req.start`.
|
|
||||||
///
|
|
||||||
/// One operation, deliberately. `open` is where a start position is
|
|
||||||
/// *expressible*, so no caller has to sequence load-then-seek and no caller
|
|
||||||
/// can race the engine's own load. An engine that cannot start at an offset
|
|
||||||
/// natively must absorb that internally (defer until loaded, or re-open) —
|
|
||||||
/// it is the only layer that knows when it is able to.
|
|
||||||
fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError>;
|
|
||||||
|
|
||||||
fn play(&mut self) -> Result<(), PlayerError>;
|
|
||||||
fn pause(&mut self) -> Result<(), PlayerError>;
|
|
||||||
|
|
||||||
/// Stop and release the current item. Must be idempotent, and must leave the
|
|
||||||
/// engine producing no audio — DR-2xx exists because "stopped" and "silent"
|
|
||||||
/// were not the same thing.
|
|
||||||
fn close(&mut self) -> Result<(), PlayerError>;
|
|
||||||
|
|
||||||
/// Seek to an absolute position on the item's timeline.
|
|
||||||
///
|
|
||||||
/// The engine decides in-place vs re-open. Callers never choose.
|
|
||||||
fn seek(&mut self, to: Duration) -> Result<(), PlayerError>;
|
|
||||||
|
|
||||||
fn set_volume(&mut self, volume: Volume) -> Result<(), PlayerError>;
|
|
||||||
fn set_rate(&mut self, rate: f64) -> Result<(), PlayerError>;
|
|
||||||
fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError>;
|
|
||||||
fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError>;
|
|
||||||
|
|
||||||
/// One coherent read of everything the UI consumes.
|
|
||||||
fn snapshot(&self) -> PlaybackSnapshot;
|
|
||||||
|
|
||||||
/// Engine capabilities, so callers can adapt without naming engines.
|
|
||||||
fn capabilities(&self) -> Capabilities;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct OpenRequest {
|
|
||||||
pub media: MediaItem,
|
|
||||||
pub selection: StreamSelection, // url + transport + playback kind
|
|
||||||
pub start: Duration, // Duration::ZERO for "from the beginning"
|
|
||||||
pub audio_track: Option<i32>,
|
|
||||||
pub subtitle_track: Option<i32>,
|
|
||||||
pub autoplay: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PlaybackSnapshot {
|
|
||||||
pub phase: Phase,
|
|
||||||
pub position: Duration,
|
|
||||||
pub duration: Option<Duration>,
|
|
||||||
pub seekable: bool,
|
|
||||||
pub volume: Volume,
|
|
||||||
pub rate: f64,
|
|
||||||
pub audio_track: Option<i32>,
|
|
||||||
pub subtitle_track: Option<i32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `Opening` is the state today's code cannot express, and the direct cause of
|
|
||||||
/// DR-241: a seek arriving with nothing loaded had no phase to be rejected or
|
|
||||||
/// queued against, so it was simply lost.
|
|
||||||
pub enum Phase { Idle, Opening, Ready, Playing, Paused, Ended, Failed(String) }
|
|
||||||
```
|
|
||||||
|
|
||||||
Engines emit `PlayerEvent` for phase, position, track and error changes. Emitting
|
|
||||||
is part of the contract, and the conformance suite asserts it — an engine that
|
|
||||||
stays silent fails, which is what would have caught DR-239 the day it landed.
|
|
||||||
|
|
||||||
### What this deletes
|
|
||||||
|
|
||||||
- `determine_video_seek_strategy` and `VideoSeekStrategy` — replaced by
|
|
||||||
`seek()` + `capabilities()`. The command layer stops deciding how engines seek.
|
|
||||||
- The reload orchestration in `player_seek_video` — moves inside the engines that
|
|
||||||
need it.
|
|
||||||
- `#[cfg(target_os = "linux")]` branches in command handlers.
|
|
||||||
- Frontend playback-state flags, which become reads of `snapshot()`.
|
|
||||||
|
|
||||||
### IPC
|
|
||||||
|
|
||||||
No new commands. Existing ones keep their names and shapes; they become thin
|
|
||||||
delegations. `PlayerStatus` gains nothing the frontend does not already receive.
|
|
||||||
Regenerate `bindings.ts` only if `PlaybackSnapshot` is exposed directly — prefer
|
|
||||||
mapping it onto the existing `PlayerStatus` so this stays invisible at the wire.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
This is the half that makes the abstraction worth having, and it is the reason to
|
|
||||||
do it rather than keep patching.
|
|
||||||
|
|
||||||
### 1. A conformance suite, run against every engine
|
|
||||||
|
|
||||||
One set of tests, parameterised over implementations. Any `MediaPlayer` must pass
|
|
||||||
it; a new engine is "done" when it does.
|
|
||||||
|
|
||||||
```
|
|
||||||
conformance::run(&mut engine, fixture) covering:
|
|
||||||
open(start = ZERO) -> phase Ready|Playing, position ~0
|
|
||||||
open(start = 10min) -> position within tolerance of 10min, NEVER 0 [DR-241]
|
|
||||||
seek while Opening -> honoured once Ready, not discarded [DR-241]
|
|
||||||
seek on a transcoded stream -> position lands, by whatever means [DR-238]
|
|
||||||
pause / play -> phase changes AND an event is emitted [DR-239]
|
|
||||||
close -> phase Idle, silent, idempotent
|
|
||||||
close during Opening -> no playback ever starts [audio-on-exit]
|
|
||||||
volume / rate / track select -> reflected in snapshot()
|
|
||||||
```
|
|
||||||
|
|
||||||
The `open(start = 10min)` and `seek while Opening` cases are the ones that fail
|
|
||||||
on today's code. They are written first, and they are the acceptance criterion.
|
|
||||||
|
|
||||||
### 2. `FakePlayer`
|
|
||||||
|
|
||||||
A deterministic in-memory implementation with a controllable clock. Lets
|
|
||||||
`PlayerController`, autoplay, queue, sleep-timer and session logic be tested with
|
|
||||||
no mpv, no device, no network — most of which is currently only reachable through
|
|
||||||
a real engine.
|
|
||||||
|
|
||||||
### 3. Per-engine runs
|
|
||||||
|
|
||||||
| Engine | Where | Note |
|
|
||||||
|---|---|---|
|
|
||||||
| `FakePlayer` | `cargo test` | Always. |
|
|
||||||
| `MpvPlayer` | `cargo test`, Linux | libmpv is already in the builder image (the Linux build links it), so **no CI toolchain install** — see CLAUDE.md. Needs a tiny local fixture file; generate it in-test rather than committing media. |
|
|
||||||
| `ExoPlayer` | instrumented, on device | Not in the standard CI job. Run via `scripts/` on a connected device; record results in the PR. |
|
|
||||||
| `WebviewPlayer` | vitest | Against a stubbed element, as `html5Adapter` is tested today. |
|
|
||||||
|
|
||||||
An engine that cannot run in CI still has the same suite; it is just run by hand.
|
|
||||||
That is the point of writing it once.
|
|
||||||
|
|
||||||
## Migration
|
|
||||||
|
|
||||||
Strangler, not a rewrite. Each step ships independently and leaves the app working.
|
|
||||||
|
|
||||||
1. **DR-242** Define `MediaPlayer`, `OpenRequest`, `PlaybackSnapshot`, `Phase`,
|
|
||||||
`Capabilities`. No implementations. Compiles alongside `PlayerBackend`.
|
|
||||||
2. **DR-243** `FakePlayer` + the conformance suite. The suite fails against
|
|
||||||
nothing yet — it is the specification.
|
|
||||||
3. **DR-244** `MpvPlayer` implementing `MediaPlayer`, wrapping today's
|
|
||||||
`MpvBackend` internals. Make conformance pass, including `open(start)`.
|
|
||||||
4. **DR-245** `PlayerController` talks to `MediaPlayer`. `PlayerBackend` retained
|
|
||||||
behind an adapter so the other engines keep working.
|
|
||||||
5. **DR-246** Move seek strategy and reload orchestration out of
|
|
||||||
`commands/player/mod.rs` into the engines; delete `seek.rs`'s truth table.
|
|
||||||
|
|
||||||
**Shipped with a deviation.** The engine cannot own this outright:
|
|
||||||
re-negotiating a stream needs the repository, which sits *above* the engine.
|
|
||||||
So the engine *declares* `seeks_transcoded_in_place` and the caller acts on
|
|
||||||
it. That removes the defect — nobody guesses on another component's behalf,
|
|
||||||
and adding an engine no longer means editing a shared table — without
|
|
||||||
pretending an engine can reach upward. `determine_video_seek_strategy`
|
|
||||||
survives as a correctly-typed decision over declared abilities rather than
|
|
||||||
being deleted; the defect was its *input*, not its existence.
|
|
||||||
6. **DR-247** `ExoPlayerPlayer`; conformance on device.
|
|
||||||
7. **DR-248** `WebviewPlayer`; retire the adapter shim.
|
|
||||||
8. **DR-249** Delete `PlayerBackend` and the frontend playback-state flags.
|
|
||||||
|
|
||||||
Steps 1–3 are pure addition and risk nothing. Step 5 is where today's defect
|
|
||||||
classes actually die.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- Stream selection (which URL, which quality) — that is
|
|
||||||
[backend-owned-stream-selection.md](backend-owned-stream-selection.md), and
|
|
||||||
this spec consumes its `StreamSelection` rather than duplicating it.
|
|
||||||
- Rendering surfaces and compositing.
|
|
||||||
- Any user-visible behaviour change. If one appears, it is a bug in the migration.
|
|
||||||
- Replacing hls.js or changing the transcode path.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [ ] The conformance suite exists and `open(start = 10min)` fails against the
|
|
||||||
pre-migration mpv path — proving it reproduces DR-241 — then passes.
|
|
||||||
- [ ] `FakePlayer` lets at least one controller-level test run with no engine.
|
|
||||||
- [ ] `determine_video_seek_strategy` is deleted, not merely bypassed.
|
|
||||||
- [ ] No `cfg(target_os = ...)` remains in `commands/player/`.
|
|
||||||
- [ ] `bun run check`, `bun run test`, `bun run format:check`, `bun run lint` pass.
|
|
||||||
- [ ] `cargo fmt`, `cargo clippy -D warnings`, `bun run test:rust` pass.
|
|
||||||
- [ ] `bun run check:boundary` passes.
|
|
||||||
- [ ] `// TRACES:` on new code; `bun run traces:validate` passes; coverage stays
|
|
||||||
at or above the CI ratchet.
|
|
||||||
- [ ] Manual: resume, skip on a transcoded item, pause/play, and exit-while-playing
|
|
||||||
verified on Linux **and** Android before `PlayerBackend` is deleted.
|
|
||||||
|
|
||||||
## Notes for the implementer
|
|
||||||
|
|
||||||
- **Write the conformance suite before the second engine**, or it will encode
|
|
||||||
whatever the first engine happens to do.
|
|
||||||
- `close()` must mean *silent*. The bug that motivated this spec had `stop` being
|
|
||||||
called, reported, and audible afterwards.
|
|
||||||
- Do not let `Capabilities` grow into engine sniffing. If a caller branches on
|
|
||||||
the engine's identity, the contract is missing something — add it there.
|
|
||||||
- A parallel Claude session may be active in this repo — `git diff` before
|
|
||||||
"repairing" unexpected changes.
|
|
||||||
@@ -1,405 +0,0 @@
|
|||||||
# Spec: Multi-user profiles with PIN switching
|
|
||||||
|
|
||||||
**Status:** Proposed
|
|
||||||
**Requirements:** UR-082, UR-083, UR-084 → IR-034, DR-267 … DR-276
|
|
||||||
**UX spec:** [ux-flows.md](../ux-flows.md) — new "Who's watching" section
|
|
||||||
**Destination on completion:**
|
|
||||||
- [09-security.md](../architecture/09-security.md) — new "Profile locking" section beside *Authentication Token Storage* (PIN gate, what it does and does not protect)
|
|
||||||
- [01-rust-backend.md](../architecture/01-rust-backend.md) — profile switch orchestration beside the session state machine
|
|
||||||
- [02-svelte-frontend.md](../architecture/02-svelte-frontend.md) — profile picker + lock state in the nav guard
|
|
||||||
- [08-database-design.md](../architecture/08-database-design.md) — `user_pins`, `user_item_visibility`, `download_grants`, per-user vs device settings
|
|
||||||
- [06-downloads-and-offline.md](../architecture/06-downloads-and-offline.md) — shared files, per-user grants, refcounted deletion
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
A shared device (family TV, tablet) can hold several Jellyfin accounts **from the
|
|
||||||
same server** and switch between them in a couple of taps. Adult accounts can set
|
|
||||||
a numeric PIN that gates the switch; child accounts have no PIN and are one tap
|
|
||||||
away. An adult who forgets their PIN signs in with their Jellyfin password
|
|
||||||
instead — there is no separate reset flow.
|
|
||||||
|
|
||||||
The feature is **opt-in and invisible until used**: one account with no PIN
|
|
||||||
behaves exactly as the app does today.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
JellyTau already stores users per server ([schema.rs](../../src-tauri/src/storage/schema.rs)
|
|
||||||
`users`), keeps a token per user in the keyring, and exposes
|
|
||||||
`storage_get_users` / `storage_set_active_user` — none of which any UI calls. The
|
|
||||||
only way to change account today is `auth_logout`, which calls Jellyfin's logout
|
|
||||||
endpoint and **invalidates the token server-side**, forcing a full password login
|
|
||||||
every time. On a living-room device shared by a family that is the difference
|
|
||||||
between "switch to the kids' profile" and "find the password".
|
|
||||||
|
|
||||||
Two defects block simply exposing the existing commands, and both are the real
|
|
||||||
work of this spec:
|
|
||||||
|
|
||||||
1. **The metadata cache is server-scoped, not user-scoped.** `items`, `libraries`,
|
|
||||||
`genres` and `thumbnails` carry `server_id` but no user. Jellyfin's parental
|
|
||||||
controls filter *server responses*, but the cache-first read path returns local
|
|
||||||
rows before the server answers — so a child profile on a device a parent has
|
|
||||||
browsed sees the parent's titles and artwork.
|
|
||||||
2. **Download availability is answered per item, not per user.**
|
|
||||||
`offline_is_available` ([offline.rs](../../src-tauri/src/commands/offline.rs))
|
|
||||||
counts completed rows for an `item_id` with no user predicate, so a child's UI
|
|
||||||
marks a parent's download as available and can play it offline.
|
|
||||||
|
|
||||||
## Layer assignment
|
|
||||||
|
|
||||||
| Logic / responsibility | Layer | Why it belongs there |
|
|
||||||
|------------------------|-------|----------------------|
|
|
||||||
| Which profiles exist, and each one's unlock method | Rust | Derived from the `users` table + PIN presence. The frontend must never infer "this is a child account" from anything; it renders an opaque `unlock_method` |
|
|
||||||
| PIN verification, attempt counting, lockout window | Rust | A gate the frontend could skip is not a gate. The counter and the clock must live where the webview cannot reach them |
|
|
||||||
| PIN hashing (KDF, salt, cost) | Rust | Security primitive; changes with threat model, never with UI |
|
|
||||||
| Switch orchestration (stop player, drain sync queue, swap repository, restart poller) | Rust | Owns every piece of state being torn down; ordering is a correctness invariant |
|
|
||||||
| Cache visibility stamping and filtering | Rust | Domain data access control. Any leak here is a content-safety bug |
|
|
||||||
| Download grants, refcounted file deletion | Rust | Storage domain; the frontend has no concept of a file refcount |
|
|
||||||
| Same-server constraint on adding a profile | Rust | Domain rule about what a profile *is*, not a form-validation nicety |
|
|
||||||
| Whether to show the picker at startup | Rust | Depends on profile count + PIN presence + a stored setting, all backend state |
|
|
||||||
| Profile picker grid, avatars, transitions | Frontend | Pure presentation |
|
|
||||||
| PIN pad layout, digit entry, shake-on-wrong | Frontend | Input handling; changes only if the UI is redesigned |
|
|
||||||
| "Use password instead" form | Frontend | Presentation over the existing `auth_login` |
|
|
||||||
| Ordering of tiles (last used first) | Frontend | Presentation preference over data Rust already returns |
|
|
||||||
|
|
||||||
Borderline: *ordering of tiles* could be argued into Rust since `last_used_at`
|
|
||||||
comes from the DB. Rust returns the timestamp; the frontend decides it means
|
|
||||||
"leftmost". Tie-breaker: it changes only if the UI is redesigned.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### Threat model — state it plainly
|
|
||||||
|
|
||||||
The PIN is a **switching gate against a member of the household**, not at-rest
|
|
||||||
protection against an attacker with the disk. Tokens stay in the keyring exactly
|
|
||||||
as they are today ([credentials.rs](../../src-tauri/src/credentials.rs)); the PIN
|
|
||||||
does **not** encrypt them.
|
|
||||||
|
|
||||||
This is a deliberate choice, and the rejected alternative matters enough to
|
|
||||||
record: wrapping each token with a key derived from its PIN would resist an
|
|
||||||
offline attacker, but a locked profile would then be *unable to act as itself* —
|
|
||||||
no resuming its downloads after a restart, no draining its `sync_queue`, no
|
|
||||||
session polling — until someone walked past and typed four digits. On a device
|
|
||||||
that reboots nightly that is a worse product for a threat this feature does not
|
|
||||||
face. A four-digit code was never going to resist an offline attack anyway.
|
|
||||||
|
|
||||||
Consequences to document in 09-security.md rather than discover later:
|
|
||||||
|
|
||||||
- Anyone with the SQLite file and keyring access has every profile's token,
|
|
||||||
PIN or not.
|
|
||||||
- The PIN stops a child *becoming a parent*. It does not restrict content. Content
|
|
||||||
restriction is Jellyfin's server-side parental controls, which most self-hosters
|
|
||||||
have never configured — the UI must say so when a PIN-less profile is created.
|
|
||||||
|
|
||||||
### Schema
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Migration 024
|
|
||||||
CREATE TABLE user_pins (
|
|
||||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
pin_hash TEXT NOT NULL, -- Argon2id PHC string; salt is embedded
|
|
||||||
failed_count INTEGER DEFAULT 0,
|
|
||||||
locked_until TEXT, -- RFC3339; NULL when not locked out
|
|
||||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- What the server has actually shown to this user. NOT a maintained index:
|
|
||||||
-- written as a byproduct of the cache write path, so it cannot disagree with
|
|
||||||
-- what the server returned.
|
|
||||||
CREATE TABLE user_item_visibility (
|
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
item_id TEXT NOT NULL,
|
|
||||||
seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (user_id, item_id)
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_visibility_user ON user_item_visibility(user_id);
|
|
||||||
|
|
||||||
-- Same, at library granularity, from each user's /UserViews.
|
|
||||||
CREATE TABLE user_libraries (
|
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
library_id TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (user_id, library_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Downloads: one file, many claimants.
|
|
||||||
CREATE TABLE download_grants (
|
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
download_id INTEGER NOT NULL REFERENCES downloads(id) ON DELETE CASCADE,
|
|
||||||
granted_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (user_id, download_id)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Migration of existing installs is not optional.** Every current cache row and
|
|
||||||
download predates the concept of a user. Migration 024 backfills
|
|
||||||
`user_item_visibility` and `download_grants` for the single existing user (and,
|
|
||||||
if somehow several `users` rows exist, for the one with `is_active = 1`).
|
|
||||||
Without the backfill an upgrading user's library goes blank.
|
|
||||||
|
|
||||||
### Cache scoping — a byproduct, not an index
|
|
||||||
|
|
||||||
The reason this is tractable: the repository **already carries the user**.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct OfflineRepository {
|
|
||||||
db_service: Arc<RusqliteService>,
|
|
||||||
server_id: String,
|
|
||||||
user_id: String, // already there, already used for user_data joins
|
|
||||||
}
|
|
||||||
```
|
|
||||||
[offline.rs:87](../../src-tauri/src/repository/offline.rs#L87)
|
|
||||||
|
|
||||||
Every row enters the cache because *some specific user's* request returned it, and
|
|
||||||
`save_to_cache` ([offline.rs:395](../../src-tauri/src/repository/offline.rs#L395))
|
|
||||||
— the single write choke point, called only from
|
|
||||||
[hybrid.rs](../../src-tauri/src/repository/hybrid.rs) — knows who that is. It
|
|
||||||
stamps `user_item_visibility` in the same transaction as the item. There is no
|
|
||||||
reconciliation job and no way for the stamp to drift from what the server said,
|
|
||||||
because the stamp *is* the record of what the server said.
|
|
||||||
|
|
||||||
Reads join through it. 43 of the 64 `FROM items` sites live in
|
|
||||||
`repository/offline.rs`, where `self.user_id` is already in scope.
|
|
||||||
|
|
||||||
**The 21 sites outside the repository are triaged, not blanket-scoped:**
|
|
||||||
|
|
||||||
| Group | Disposition |
|
|
||||||
|-------|-------------|
|
|
||||||
| Browse/query paths returning lists to the UI | Must scope |
|
|
||||||
| By-id lookups from an already-authorised context (download worker resolving an item it holds a grant for; queued-row stream URL lookup) | Not scoped — authorisation happened upstream |
|
|
||||||
| Maintenance (`smart_cache` eviction, `pinning`) | Not scoped — deliberately device-wide |
|
|
||||||
|
|
||||||
The triage result is recorded in 08-database-design.md, because "why isn't this
|
|
||||||
one scoped?" is exactly what a future change gets wrong.
|
|
||||||
|
|
||||||
**Known limit — revocation drift.** The stamp can never show *more* than the
|
|
||||||
server showed, but it does not shrink when a parent tightens permissions. Mitigation:
|
|
||||||
on unlock while online, re-derive `user_libraries` from `/UserViews` and drop
|
|
||||||
visibility rows for libraries that disappeared. Offline, the cache stays
|
|
||||||
stale-permissive. This is a stated property, not a bug.
|
|
||||||
|
|
||||||
**Enforcement.** `scripts/check-cache-scope.sh` fails if `FROM items` appears
|
|
||||||
outside an allowlist of modules. With writes funnelled and 43 reads in one file
|
|
||||||
the allowlist is short enough to mean something — unlike `check:boundary`, which
|
|
||||||
had to pattern-match literals. It will not catch a missed join *inside* the
|
|
||||||
repository; it will catch a new query appearing in a random command file, which
|
|
||||||
is the realistic drift.
|
|
||||||
|
|
||||||
### Downloads — shared files, per-user grants
|
|
||||||
|
|
||||||
The file layout already assumes sharing: paths are content-derived
|
|
||||||
(`{base}/{series}/{S01E02 - Name}`,
|
|
||||||
[download/mod.rs:1052](../../src-tauri/src/commands/download/mod.rs#L1052)) while
|
|
||||||
rows are keyed `UNIQUE(item_id, user_id)` — so two profiles downloading the same
|
|
||||||
episode already aim at one path and clobber each other. Formalising:
|
|
||||||
|
|
||||||
- A second profile requesting an already-downloaded item inserts a **grant**. No
|
|
||||||
bytes transferred; immediately available.
|
|
||||||
- `offline_is_available` joins through grants instead of counting rows per item.
|
|
||||||
- `download_cancel` ([download/mod.rs:1300](../../src-tauri/src/commands/download/mod.rs#L1300))
|
|
||||||
drops the caller's grant and unlinks the file **only when the last grant goes**.
|
|
||||||
It currently deletes unconditionally, which under sharing would yank a file from
|
|
||||||
under another profile.
|
|
||||||
- Budget is naturally shared: the file is counted once. Eviction picks files with
|
|
||||||
no recent access across *any* grant.
|
|
||||||
- A grant is not an entitlement. On unlock while online, grants for items the
|
|
||||||
profile can no longer see are dropped, alongside the visibility re-derivation.
|
|
||||||
|
|
||||||
### Device ID
|
|
||||||
|
|
||||||
[device_get_id](../../src-tauri/src/commands/device.rs#L27) mints one UUID per
|
|
||||||
installation, sent as `DeviceId` on every request
|
|
||||||
([client.rs:60](../../src-tauri/src/jellyfin/client.rs#L60)). Jellyfin uses it to
|
|
||||||
identify a *session* — the Dashboard → Devices row, and the target the
|
|
||||||
remote-control feature casts to.
|
|
||||||
|
|
||||||
**Decision pending an empirical test** (see Open questions). Shipping default is a
|
|
||||||
per-profile derived ID, `uuid5(device_uuid, user_id)`, which makes each family
|
|
||||||
member a distinct device entry so playback history attributes cleanly and the
|
|
||||||
sessions list can tell "this TV, Dad" from "this TV, Kid". If the test shows
|
|
||||||
Jellyfin tolerates a shared ID *and* the merged view is preferred, one line
|
|
||||||
changes.
|
|
||||||
|
|
||||||
### Switch orchestration
|
|
||||||
|
|
||||||
`profiles_switch` is **not** `auth_logout`. Logout invalidates the token
|
|
||||||
server-side; a switch must leave the outgoing profile able to come back with one
|
|
||||||
tap. Ordering, in Rust, as a state machine over a `ProfileSession` so it is
|
|
||||||
unit-testable without a player or a server:
|
|
||||||
|
|
||||||
1. Pause playback and tear down the queue (the queue cannot outlive its owner —
|
|
||||||
a straggler would report the outgoing profile's episode against the incoming one).
|
|
||||||
2. Drain or park `sync_queue` for the outgoing user.
|
|
||||||
3. Stop the session poller; unregister MPRIS / MediaSession metadata.
|
|
||||||
4. Destroy the repository handle.
|
|
||||||
5. Flip `users.is_active`.
|
|
||||||
6. Build the new repository, restart the poller, re-derive visibility and grants
|
|
||||||
if online.
|
|
||||||
7. Emit `profile-switched`.
|
|
||||||
|
|
||||||
Two hazards, both already documented in CLAUDE.md and both reached from a new
|
|
||||||
direction here: never call blocking APIs from player event callbacks, and never
|
|
||||||
hold a lock across a `match` scrutinee. Teardown touches every one of those paths
|
|
||||||
at once.
|
|
||||||
|
|
||||||
### Lock state vs. playback
|
|
||||||
|
|
||||||
"Locked" and "who is the active profile" are **different state**. Re-lock (idle
|
|
||||||
timeout, off by default) flips only the first:
|
|
||||||
|
|
||||||
- Audio keeps playing and keeps reporting as the profile that started it.
|
|
||||||
- The lockscreen / MediaSession keeps full transport control over the **existing
|
|
||||||
queue** — play, pause, seek, next, prev. Nothing on the lockscreen browses or
|
|
||||||
starts new content, so [MediaSessionCompat](../architecture/05-platform-backends.md)
|
|
||||||
needs no changes at all.
|
|
||||||
- The locked UI refuses anything reaching past the current queue: browsing,
|
|
||||||
search, new playback, downloads, settings, switching profile without the PIN.
|
|
||||||
|
|
||||||
Two rules keep it coherent:
|
|
||||||
|
|
||||||
- **Never re-lock while something is playing.** The idle timer starts when
|
|
||||||
playback stops, not when the UI goes quiet. This removes almost all of the
|
|
||||||
conflict on its own.
|
|
||||||
- **Unlocking to a *different* profile stops playback.** Unlocking to the same
|
|
||||||
profile leaves everything running.
|
|
||||||
|
|
||||||
The timer lives in Rust beside the player state machine: it needs authoritative
|
|
||||||
playback state, and a frontend timer dies with the webview on Android.
|
|
||||||
|
|
||||||
### Startup
|
|
||||||
|
|
||||||
The picker appears only when the last-used profile has a PIN, **or** more than one
|
|
||||||
profile exists and "ask who's watching" is on. Otherwise startup resumes the last
|
|
||||||
account exactly as [auth_initialize](../../src-tauri/src/commands/auth.rs#L19)
|
|
||||||
does today. One account, no PIN → the user never sees any of this.
|
|
||||||
|
|
||||||
### Commands
|
|
||||||
|
|
||||||
Names match the Rust fns; top-level params auto-convert to camelCase.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
profiles_list() -> Vec<Profile>
|
|
||||||
profiles_startup_target() -> StartupTarget // Resume{user_id} | Picker
|
|
||||||
profiles_unlock(user_id: String, pin: Option<String>) -> UnlockOutcome
|
|
||||||
profiles_unlock_with_password(user_id: String, password: String) -> UnlockOutcome
|
|
||||||
profiles_add(username: String, password: String, pin: Option<String>) -> Profile
|
|
||||||
profiles_set_pin(user_id: String, current_pin: Option<String>, new_pin: Option<String>)
|
|
||||||
profiles_remove(user_id: String, forget_downloads: bool)
|
|
||||||
```
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[derive(Serialize, Type)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct Profile {
|
|
||||||
pub user_id: String,
|
|
||||||
pub username: String,
|
|
||||||
pub avatar_tag: Option<String>,
|
|
||||||
pub unlock_method: UnlockMethod,
|
|
||||||
pub last_used_at: Option<String>,
|
|
||||||
pub is_active: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Type)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub enum UnlockMethod { None, Pin }
|
|
||||||
|
|
||||||
#[derive(Serialize, Type)]
|
|
||||||
#[serde(tag = "type", rename_all = "camelCase")]
|
|
||||||
pub enum UnlockOutcome {
|
|
||||||
Ok { user_id: String },
|
|
||||||
WrongPin { attempts_remaining: u32 },
|
|
||||||
LockedOut { until: String },
|
|
||||||
NeedsPassword,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Event: `profile-switched` (kebab-case), payload `{ userId }`.
|
|
||||||
|
|
||||||
`profiles_add` takes no server URL — it authenticates against the *current*
|
|
||||||
server. That is the same-server constraint, enforced in Rust rather than by
|
|
||||||
omitting a form field.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- Multiple servers. The schema already supports it (`users.server_id`); only the
|
|
||||||
flow is constrained. Not a schema change to undo later.
|
|
||||||
- Per-profile content restriction. That is Jellyfin's, server-side.
|
|
||||||
- Profile avatars uploaded locally — use the server's `avatar_tag`.
|
|
||||||
- Biometric unlock.
|
|
||||||
- Idle re-lock is specified above but ships **off by default** and last.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [ ] One account with no PIN: startup, playback and downloads are byte-identical to today.
|
|
||||||
- [ ] A second profile can be added with a password, against the current server only.
|
|
||||||
- [ ] A PIN-less profile switches in one tap; a PIN profile requires the PIN.
|
|
||||||
- [ ] Wrong PIN decrements attempts, then locks out with a stated window; the counter survives an app restart.
|
|
||||||
- [ ] "Use password instead" signs in and offers to set a new PIN.
|
|
||||||
- [ ] Switching does **not** invalidate the outgoing profile's token — switching back needs no password.
|
|
||||||
- [ ] A child profile does not see cached items or downloads belonging to another profile, online or offline.
|
|
||||||
- [ ] Two profiles requesting the same item produce one file and two grants; removing one grant keeps the file.
|
|
||||||
- [ ] Upgrading an existing install shows the same library it showed before (backfill works).
|
|
||||||
- [ ] Playback survives an idle re-lock; lockscreen transport still works; unlocking to a different profile stops it.
|
|
||||||
- [ ] `bun run check`, `bun run test`, `bun run format:check`, `bun run lint` pass.
|
|
||||||
- [ ] `cargo fmt` clean, `cargo clippy -D warnings` clean, `bun run test:rust` passes.
|
|
||||||
- [ ] `bun run check:boundary` and the new `check:cache-scope` pass.
|
|
||||||
- [ ] New code carries `// TRACES:` comments; `bun run traces:validate` passes.
|
|
||||||
- [ ] `bindings.ts` regenerated.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
**Rust**
|
|
||||||
- PIN: correct/incorrect/lockout/expiry-of-lockout; counter persists across a
|
|
||||||
service restart; a cleared PIN removes the row.
|
|
||||||
- Switch orchestration as a pure state machine over `ProfileSession` — assert the
|
|
||||||
teardown *ordering*, no player or server needed. This is the part that would
|
|
||||||
otherwise only ever be hand-tested.
|
|
||||||
- Visibility: `save_to_cache` stamps; a second user's read of the same item
|
|
||||||
returns nothing; migration backfill populates the existing user.
|
|
||||||
- Grants: second grant transfers no bytes; cancel with two grants keeps the file;
|
|
||||||
cancel of the last grant unlinks it.
|
|
||||||
- Same-server: `profiles_add` against a different URL is rejected.
|
|
||||||
|
|
||||||
**Frontend**
|
|
||||||
- Picker renders from `profiles_list` with no unlock-method inference of its own.
|
|
||||||
- PIN pad calls `profiles_unlock` and renders each `UnlockOutcome` variant; it
|
|
||||||
never compares a PIN or counts an attempt locally.
|
|
||||||
- `tauriIntegration.test.ts` gains the new commands (camelCase param guard).
|
|
||||||
|
|
||||||
**Manual — the honest gap.** End-to-end multi-user needs two real accounts with
|
|
||||||
differing library permissions on a real server; CI has neither. The state-machine
|
|
||||||
extraction above is what keeps the risky half testable. The rest is a documented
|
|
||||||
manual pass in the release checklist.
|
|
||||||
|
|
||||||
## TRACES
|
|
||||||
|
|
||||||
| Piece | Tag |
|
|
||||||
|-------|-----|
|
|
||||||
| `profiles_*` commands | `UR-082 \| DR-267` |
|
|
||||||
| PIN hash + lockout | `UR-083 \| DR-268` |
|
|
||||||
| Password fallback | `UR-084 \| DR-269` |
|
|
||||||
| Switch orchestration | `UR-082 \| DR-270` |
|
|
||||||
| Visibility stamp/filter | `UR-082 \| DR-271` |
|
|
||||||
| Download grants | `UR-082 \| IR-034, DR-272` |
|
|
||||||
| Per-profile device ID | `UR-082 \| DR-273` |
|
|
||||||
| Startup target | `UR-082 \| DR-274` |
|
|
||||||
| Idle re-lock | `UR-083 \| DR-275` |
|
|
||||||
| Picker + PIN pad UI | `UR-082, UR-083 \| DR-276` |
|
|
||||||
|
|
||||||
## Open questions
|
|
||||||
|
|
||||||
1. **Does authenticating a second user with an in-use `DeviceId` invalidate the
|
|
||||||
first user's token?** Two minutes with two accounts: log in as A, log in as B
|
|
||||||
with the same DeviceId, then call `/Sessions` with A's token. Decides whether
|
|
||||||
the per-profile device ID is a preference or a requirement.
|
|
||||||
2. Should removing a profile default to deleting its exclusive downloads, or
|
|
||||||
keeping them? Spec currently makes it an explicit flag.
|
|
||||||
|
|
||||||
## Notes for the implementer
|
|
||||||
|
|
||||||
- A parallel Claude session may be active in this repo — `git diff` before
|
|
||||||
"repairing" unexpected changes.
|
|
||||||
- `auth_logout` stays exactly as it is. Do not refactor switching through it; the
|
|
||||||
server-side invalidation is the whole reason it is unsuitable.
|
|
||||||
- The docs say the keyring key is `jellytau::{server_id}::{user_id}::access_token`;
|
|
||||||
[credentials.rs:244](../../src-tauri/src/credentials.rs#L244) actually writes
|
|
||||||
`access_token:{user_id}`. Fix the doc, not the code — changing the key format
|
|
||||||
would strand every existing token.
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
# Spec: Playback backend unification — findings and strategy
|
|
||||||
|
|
||||||
**Status:** Accepted (analysis; no code changes)
|
|
||||||
**Requirements:** IR-004, UR-031, UR-032, UR-033 — revises the "Platform Playback Backend Parity" issue in requirements.md
|
|
||||||
**UX spec:** n/a
|
|
||||||
**Supersedes / revises:** informed the Android native-video and audio-parity
|
|
||||||
work (both since shipped — see
|
|
||||||
[05-platform-backends.md](../architecture/05-platform-backends.md)) and
|
|
||||||
[windows-native-audio-backend.md](windows-native-audio-backend.md), still open
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
This spec records the outcome of an investigation into unifying JellyTau's
|
|
||||||
playback backends (Linux/MPV, Android/ExoPlayer, Windows/webview) onto a single
|
|
||||||
engine with hardware acceleration everywhere. **The conclusion is that video
|
|
||||||
cannot be unified onto a native engine, and should not be attempted.** Audio
|
|
||||||
*can* be, and that is where the remaining specs direct effort.
|
|
||||||
|
|
||||||
No code changes follow from this spec directly. It exists so the decision is
|
|
||||||
written down with its evidence, and so a future session does not re-run the same
|
|
||||||
investigation.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
The requirements doc carries a "Platform Playback Backend Parity" issue noting
|
|
||||||
that audio settings work on Linux but not Android, and proposing eventual
|
|
||||||
convergence. The natural next question — "should we just run one engine
|
|
||||||
everywhere?" — needed answering before spending effort on per-backend patches.
|
|
||||||
|
|
||||||
The investigation also surfaced that several statements in requirements.md and in
|
|
||||||
code comments are factually wrong. Those corrections are part of the deliverable.
|
|
||||||
|
|
||||||
## Findings
|
|
||||||
|
|
||||||
### 1. The current architecture is not what the docs describe
|
|
||||||
|
|
||||||
| Platform | Audio | Video |
|
|
||||||
|----------|-------|-------|
|
|
||||||
| Linux | MPV (native, **audio-only**) | webview `<video>` + hls.js |
|
|
||||||
| Android | ExoPlayer (native) | **webview `<video>` + hls.js** |
|
|
||||||
| Windows | webview `<audio>` | webview `<video>` + hls.js |
|
|
||||||
|
|
||||||
Two surprises:
|
|
||||||
|
|
||||||
- **MPV never decodes video.** `mpv_backend.rs` sets `video = no` and
|
|
||||||
`audio-display = no` at construction. Linux video has always been the webview.
|
|
||||||
Correspondingly, `player_play_item` deliberately does *not* load into MPV on
|
|
||||||
Linux (it calls `set_current_item`, which only updates the queue).
|
|
||||||
- **Android video is also the webview.** `createAdapter()` in
|
|
||||||
`src/lib/player/adapters/index.ts` hardcodes `const effectiveKind = "html5"`
|
|
||||||
and does `void backendKind`, discarding the `use_html5_element` signal that
|
|
||||||
`get_player_status` computes in Rust. `NativePlayerAdapter` is dead code, and
|
|
||||||
ExoPlayer's `SurfaceView` path in `JellyTauPlayer.kt` is unreachable.
|
|
||||||
|
|
||||||
So video is *already* unified — on HTML5, everywhere, by accident of that
|
|
||||||
hardcode — and on the path without hardware decoding on Android.
|
|
||||||
|
|
||||||
### 2. Native video cannot be composited with a Tauri webview
|
|
||||||
|
|
||||||
This is the load-bearing finding. It is **not** an mpv limitation; it defeats
|
|
||||||
every candidate engine identically:
|
|
||||||
|
|
||||||
- **mpv**: `tauri-plugin-libmpv`'s own platform table reads Linux ⚠️
|
|
||||||
*"Experimental. Window embedding is not working."*
|
|
||||||
- **GStreamer** (wry discussion #284, 2024): *"Gstreamer was rendering above the
|
|
||||||
surface and covering all html elements."*
|
|
||||||
- **libVLC** (tauri discussion #6343, 2024): *"I had to render the webview in a
|
|
||||||
child window though because vlc kept rendering on top of it."*
|
|
||||||
|
|
||||||
Root cause, from Tauri maintainer amrbashir (tauri#9220, 2024-03-30):
|
|
||||||
|
|
||||||
> "we are limited to using Webkit2GTK on Linux and that requires a GTK window.
|
|
||||||
> While possible to add a GTK widget as a child X11 window inside raw X11 window,
|
|
||||||
> this is however a bit hacky and **it is not possible on Wayland at all**."
|
|
||||||
|
|
||||||
WebKitGTK, WebView2, and Android WebView each draw into their own compositor
|
|
||||||
surface. A native video surface is either entirely above or entirely below the
|
|
||||||
webview; it cannot interleave with HTML. Every working example in the ecosystem
|
|
||||||
is the same hack — a separate child window position-synced to a
|
|
||||||
`getBoundingClientRect()` div — which breaks on resize, scroll, and any UI drawn
|
|
||||||
over the video. For JellyTau that means the controls, subtitle overlay, and
|
|
||||||
mini-player.
|
|
||||||
|
|
||||||
The most recent comment on tauri#6343 (2026-05-23) confirms it is still unsolved:
|
|
||||||
|
|
||||||
> "I'm faking it and the window is not truly embedded, basically when the parent
|
|
||||||
> moves or resizes I reset the position and size of the libmpv window to align it
|
|
||||||
> with an HTML div."
|
|
||||||
|
|
||||||
**The principle to carry forward: audio can unify on a native engine; video
|
|
||||||
cannot, because video needs a surface and the webview owns the surface.**
|
|
||||||
|
|
||||||
> **Re-opened on Linux (2026-08-21).** This finding's general form has since been
|
|
||||||
> falsified on Android — native video now composites behind a transparent Tauri
|
|
||||||
> WebView and ships on by default (see
|
|
||||||
> [05-platform-backends.md](../architecture/05-platform-backends.md#native-video-compositing-android)).
|
|
||||||
> The evidence above is also entirely about *foreign-window embedding*; mpv's
|
|
||||||
> render API, drawing into a GL context we own inside Tauri's own GTK tree, was
|
|
||||||
> never tested. [linux-native-video-spike.md](linux-native-video-spike.md) tests
|
|
||||||
> that one claim on Linux. **Findings 3-6 below are untouched by it** - in
|
|
||||||
> particular finding 3, which is an independent disqualifier a green spike would
|
|
||||||
> not clear.
|
|
||||||
|
|
||||||
### 3. mpv would regress streaming quality
|
|
||||||
|
|
||||||
mpv has **no adaptive bitrate**. It delegates HLS to FFmpeg's demuxer, which
|
|
||||||
selects one variant at open time and never adapts; mpv#3548 (2016) requested ABR
|
|
||||||
and it never landed. `--hls-bitrate` is a static picker defaulting to `max`.
|
|
||||||
|
|
||||||
The webview path already has real ABR via hls.js. Moving video to mpv would be a
|
|
||||||
**downgrade** on every platform — no graceful degradation on weak networks, and
|
|
||||||
quality changes requiring teardown and reload.
|
|
||||||
|
|
||||||
> **Premise in doubt (2026-08-21).** "The webview path already has real ABR"
|
|
||||||
> was not verified against the URLs this app actually builds.
|
|
||||||
> `get_video_stream_url` requests a *single* rendition (one `VideoBitrate`, one
|
|
||||||
> `MaxHeight`), the frontend has **no** level-handling code (`hls.levels`,
|
|
||||||
> `LEVEL_SWITCH`, `currentLevel` appear nowhere), and this repo implements a
|
|
||||||
> quality switch by *re-opening the stream* — all of which point to a
|
|
||||||
> single-variant playlist, i.e. no ABR to lose. The decisive test is counting
|
|
||||||
> `#EXT-X-STREAM-INF` lines in a real `master.m3u8`; it needs a live server and
|
|
||||||
> has not been run. See
|
|
||||||
> [linux-native-video-spike.md](linux-native-video-spike.md).
|
|
||||||
|
|
||||||
### 4. Crossfade is architecturally blocked on mpv
|
|
||||||
|
|
||||||
mpv's audio chain is single-stream. FFmpeg's `acrossfade` is an `N→A` filter
|
|
||||||
requiring two input streams, so there is no second input to feed it. Real
|
|
||||||
crossfade needs **two libmpv instances** with manually ramped volumes. Upstream
|
|
||||||
maintainer response (mpv#4512, closed three minutes after opening):
|
|
||||||
|
|
||||||
> "No. I also find crossfading stupid and complex, so the likeliness of that
|
|
||||||
> happening is low."
|
|
||||||
|
|
||||||
GStreamer *could* do it via `audiomixer`. mpv cannot, at any reasonable cost.
|
|
||||||
|
|
||||||
### 5. Engine comparison summary
|
|
||||||
|
|
||||||
| Criterion | mpv | GStreamer | libVLC |
|
|
||||||
|-----------|-----|-----------|--------|
|
|
||||||
| Webview compositing | ❌ Linux broken | ❌ same wall | ❌ same wall |
|
|
||||||
| Adaptive bitrate HLS | ❌ none | ✅ adaptivedemux2 | ✅ adaptive module |
|
|
||||||
| Rust bindings | ⚠️ `libmpv2` active; our pin is dead | ✅ `gstreamer-rs` excellent | ❌ `vlc-rs` abandoned (2018) |
|
|
||||||
| Windows cross-MSVC | ⚠️ prebuilt DLL | ❌ pkg-config vs cargo-xwin | ❌ no better |
|
|
||||||
| Android packaging | ✅ Maven AAR (used by Findroid) | ⚠️ Cerbero/NDK, painful | ✅ mature AAR |
|
|
||||||
| ASS/SSA subtitles | ✅ libass built in | ✅ libass | ✅ libass |
|
|
||||||
| Crossfade | ❌ impossible | ✅ `audiomixer` | ⚠️ unclear |
|
|
||||||
|
|
||||||
Every candidate fails the first row, which is the disqualifying one.
|
|
||||||
|
|
||||||
### 6. Two further options ruled out
|
|
||||||
|
|
||||||
**Webview `<audio>`/`<video>` everywhere** (i.e. delete the native audio backends
|
|
||||||
too) is dead on Android: `navigator.mediaSession` is *deliberately compiled out*
|
|
||||||
of Android WebView (Chromium CL 2613133003), so lockscreen/media-notification
|
|
||||||
control would be impossible. Chromium has also never shipped `audioTracks`. It
|
|
||||||
remains fine for Windows *video*, which is what we already do.
|
|
||||||
|
|
||||||
**FFmpeg-direct / Rust-native** (`ffmpeg-next`, `rsmpeg`, Symphonia) is not
|
|
||||||
close: the safe bindings do not expose hardware decode at all, `ffmpeg-next` is
|
|
||||||
self-declared maintenance-only, and Symphonia lacks HE-AAC and gapless AAC. This
|
|
||||||
is a multi-person-year path to reach parity with what we already have.
|
|
||||||
|
|
||||||
### 7. If libmpv is ever revisited on Android
|
|
||||||
|
|
||||||
Recorded so the next investigation starts from evidence rather than repeating the
|
|
||||||
search. The `dev.jdtech.mpv:libmpv` AAR — maintained by Findroid's author, i.e.
|
|
||||||
another Jellyfin Android client — was inspected directly:
|
|
||||||
|
|
||||||
- `libmpv.so` exports the full 54-function `mpv_*` C API with **zero `Java_`
|
|
||||||
symbols**; JNI is a separate optional ~19 KB `libplayer.so`. So it is drivable
|
|
||||||
from Rust without a Java shim. (This is precisely what disqualifies libVLC,
|
|
||||||
whose Android video path hard-requires a Java `AWindow` jobject.)
|
|
||||||
- ~23 MB/ABI, versus libVLC's ~46 MB/ABI.
|
|
||||||
- 🔴 **The published AAR is built `--enable-gpl --enable-version3` — it is
|
|
||||||
GPLv3**, not LGPL. Fine for us (see [libmpv2-migration.md](libmpv2-migration.md)),
|
|
||||||
but it would be a hard constraint for anyone shipping closed source, and an
|
|
||||||
LGPL rebuild would be your own build to own.
|
|
||||||
- Top unverified risk if anyone tries this: whether `libmpv2-sys` can
|
|
||||||
cross-compile for `aarch64-linux-android` against that prebuilt `.so`. No
|
|
||||||
working example of `libmpv2` on Android was found.
|
|
||||||
|
|
||||||
None of this changes the verdict — the cost is the MediaSession/foreground-service
|
|
||||||
rewrite, not the bindings.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
1. **Do not unify video onto a native engine.** Video stays in the webview with
|
|
||||||
hls.js on all platforms. This is not a compromise — it is the configuration
|
|
||||||
that falls out of the compositing constraint, and it is the only one that
|
|
||||||
gives us ABR for free.
|
|
||||||
2. **Android native video is worth a bounded spike anyway** — not for
|
|
||||||
unification, but because ExoPlayer's `SurfaceView` path already exists and
|
|
||||||
would restore hardware decode plus ASS/SSA subtitles. See
|
|
||||||
[05-platform-backends.md](../architecture/05-platform-backends.md#native-video-compositing-android).
|
|
||||||
3. **Audio parity is the real gap** and is achievable without touching any of the
|
|
||||||
above. See [05-platform-backends.md](../architecture/05-platform-backends.md)
|
|
||||||
and [windows-native-audio-backend.md](windows-native-audio-backend.md).
|
|
||||||
4. **Migrate the dead libmpv pin** regardless of any of this. See
|
|
||||||
[libmpv2-migration.md](libmpv2-migration.md).
|
|
||||||
|
|
||||||
## Corrections to existing docs
|
|
||||||
|
|
||||||
These are factual errors found during the investigation. Fixing them is in scope
|
|
||||||
for this spec.
|
|
||||||
|
|
||||||
| Location | Says | Actually |
|
|
||||||
|----------|------|----------|
|
|
||||||
| `requirements.md` UR-031 (line ~44) | "Done (Linux only)" | Not implemented on any platform. |
|
|
||||||
| `requirements.md` DR-034 (line ~196) | "Done (Linux only)" | Not implemented anywhere — `mpv_backend.rs` has a bare `// TODO: Implement crossfade via MPV audio filters if needed`. Architecturally blocked on mpv (finding 4). |
|
|
||||||
| `requirements.md` parity matrix | Crossfade ✅ Linux / ❌ Android | ❌ / ❌ |
|
|
||||||
| `requirements.md` parity matrix | (no EQ row) | EQ is also Linux-only — `build_af_filter`/`eq_filter_entries` exist only in `mpv_backend.rs`. Same root cause, same fix. |
|
|
||||||
| `nativeAdapter.ts:11-14` | Native Android video "blocked upstream by tauri#10152" | tauri#10152 is a stale *feature request*, dead since 2024-07-01. The capability shipped in tauri commit `27d01834` (2024-09-02). Not a blocker. |
|
|
||||||
|
|
||||||
## Layer assignment
|
|
||||||
|
|
||||||
No new logic. The one boundary observation worth recording:
|
|
||||||
|
|
||||||
| Logic / responsibility | Layer | Why it belongs there |
|
|
||||||
|------------------------|-------|----------------------|
|
|
||||||
| Which video backend a platform uses (`use_html5_element`) | Rust | Already correctly computed in `get_player_status`. The frontend currently *discards* it — that is the bug, not the design. Restoring it means the frontend consumes a backend decision rather than making its own. |
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- Any code change. This spec is analysis; the sibling specs carry the work.
|
|
||||||
- iOS/macOS. Not current targets.
|
|
||||||
- Replacing hls.js.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [ ] `requirements.md` DR-034 status corrected; parity matrix updated (crossfade ❌/❌, EQ row added).
|
|
||||||
- [ ] Stale tauri#10152 comment in `nativeAdapter.ts` corrected.
|
|
||||||
- [ ] The four sibling specs exist and are linked from here.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
n/a — documentation only.
|
|
||||||
|
|
||||||
## TRACES
|
|
||||||
|
|
||||||
No new code. Requirement text changes only; DR-034's status line is the one
|
|
||||||
substantive edit.
|
|
||||||
|
|
||||||
## Notes for the implementer
|
|
||||||
|
|
||||||
- The evidence above was gathered in July 2026. The compositing constraint has
|
|
||||||
been stable since 2021 (wry#284) and is maintainer-declared unfixable, so it is
|
|
||||||
unlikely to change soon — but if someone revisits this, tauri#6343 and wry#284
|
|
||||||
are the threads to re-read first.
|
|
||||||
- A parallel Claude session may be active in this repo — `git diff` before
|
|
||||||
"repairing" unexpected changes.
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
# Spec: Enforce the unified player boundary
|
|
||||||
|
|
||||||
**Status:** Proposed — not started. The count below has not improved: ~60
|
|
||||||
`commands.player*` call sites still live outside `src/lib/player/`, and no lint
|
|
||||||
rule enforces the boundary. This remains the one stated design principle with no
|
|
||||||
automated check.
|
|
||||||
**Requirements:** ⚠️ the suggested id **DR-095 has since been allocated** to seek
|
|
||||||
clamping — allocate a fresh id (DR-215 or later) on implementation. Relates to
|
|
||||||
UR-005 and the unified-player-boundary
|
|
||||||
principle in CLAUDE.md and [02-svelte-frontend.md](../architecture/02-svelte-frontend.md)
|
|
||||||
**UX spec:** n/a — refactor, no user-visible change.
|
|
||||||
**Supersedes / revises:** n/a
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
The stated principle is that UI controls playback **only** through
|
|
||||||
`playerController` ([src/lib/player/index.ts](../../src/lib/player/index.ts)),
|
|
||||||
never by calling `commands.player*` directly. There are **52 direct call sites
|
|
||||||
outside** that facade. This spec routes the genuine playback-control calls
|
|
||||||
through the facade, narrows the principle's wording so it stops forbidding
|
|
||||||
things it never meant to forbid, and adds the lint rule that keeps it true —
|
|
||||||
because this rule is the one design principle in the audit with **no automated
|
|
||||||
check at all**, and it is also the one that drifted furthest.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
Direct `commands.player*` usage outside `src/lib/player/`, by file:
|
|
||||||
|
|
||||||
| File | Sites |
|
|
||||||
|---|---|
|
|
||||||
| [queue.ts](../../src/lib/stores/queue.ts) | 10 |
|
|
||||||
| [player/[id]/+page.svelte](../../src/routes/player/[id]/+page.svelte) | 9 |
|
|
||||||
| [VideoPlayer.svelte](../../src/lib/components/player/VideoPlayer.svelte) | 8 |
|
|
||||||
| [settings/+page.svelte](../../src/routes/settings/+page.svelte) | 5 |
|
|
||||||
| [sleepTimer.ts](../../src/lib/stores/sleepTimer.ts) / [auth.ts](../../src/lib/stores/auth.ts) / [autoplay.ts](../../src/lib/api/autoplay.ts) | 4 each |
|
|
||||||
| [preload.ts](../../src/lib/services/preload.ts) | 3 |
|
|
||||||
| [library/[id]](../../src/routes/library/[id]/+page.svelte), [playerEvents.ts](../../src/lib/services/playerEvents.ts), [playbackMode.ts](../../src/lib/stores/playbackMode.ts) | 1–2 each |
|
|
||||||
|
|
||||||
These are **not** equivalent violations, and treating them as one number is why
|
|
||||||
the rule has been easy to ignore. Three distinct groups:
|
|
||||||
|
|
||||||
**(a) Genuine violations — playback control with a facade method that already
|
|
||||||
exists.** `playerStop` ×6, `playerPlayTracks` ×4, `playerSeek` ×2,
|
|
||||||
`playerPlayAlbumTrack` ×2, `playerNext`, `playerPrevious`, `playerSkipTo`,
|
|
||||||
`playerToggleShuffle`, `playerCycleRepeat`, `playerRemoveFromQueue`,
|
|
||||||
`playerMoveInQueue`, `playerAddTrackById`, `playerAddTracksByIds`,
|
|
||||||
`playerSetSubtitleTrack`, `playerPlayItem`. The facade exposes `stop()`,
|
|
||||||
`seek()`, `next()`, `previous()`, `skipTo()`, `toggleShuffle()`,
|
|
||||||
`cycleRepeat()`, `removeFromQueue()`, `moveInQueue()`, `addTrackById()`,
|
|
||||||
`addTracksByIds()`, `setSubtitleTrack()`, `playTracks()`, `playAlbumTrack()`,
|
|
||||||
`playItem()` — every one of these has a facade equivalent that is simply not
|
|
||||||
being called. `queue.ts` is the starkest case: it imports `commands` directly
|
|
||||||
and re-implements ten methods the facade already provides.
|
|
||||||
|
|
||||||
**(b) Playback control with no facade method.** `playerPlayQueue`,
|
|
||||||
`playerGetQueue`, `playerGetStatus`, `playerEnterBackgroundAudio`,
|
|
||||||
`playerExitBackgroundAudio`, `playerSetSleepTimer`, `playerCancelSleepTimer`,
|
|
||||||
`playerPlayNextEpisode`, `playerCancelAutoplayCountdown`. In scope for the
|
|
||||||
principle, but currently *impossible* to comply with — the facade has no surface
|
|
||||||
for them. A rule that cannot be followed is not being broken so much as it is
|
|
||||||
unfinished.
|
|
||||||
|
|
||||||
**(c) Not playback control.** `playerConfigureJellyfin` ×3,
|
|
||||||
`playerDisableJellyfin`, `playerGet/SetAudioSettings`,
|
|
||||||
`playerGet/SetVideoSettings`, `playerGetEqPresets`,
|
|
||||||
`playerGet/SetAutoplaySettings`, `playerGet/SetCacheConfig`,
|
|
||||||
`playerPreloadUpcoming`. These are configuration and lifecycle calls that happen
|
|
||||||
to live under the `player_` command prefix. The principle is about *who is
|
|
||||||
authoritative for playback state* — settings CRUD isn't that.
|
|
||||||
|
|
||||||
The audit's read: the rule as written is violated 52 times, which makes real
|
|
||||||
drift indistinguishable from acceptable usage, and that ambiguity is what lets
|
|
||||||
group (a) persist. Note also that the principle **is** well-honoured where it
|
|
||||||
matters most — the read side is clean, with UI reading state exclusively from
|
|
||||||
the facade's re-exported stores. The write side is what drifted.
|
|
||||||
|
|
||||||
## Layer assignment
|
|
||||||
|
|
||||||
Frontend-internal refactor. No domain logic moves and nothing new crosses IPC —
|
|
||||||
the same Rust commands are called, through one module instead of many.
|
|
||||||
|
|
||||||
| Logic / responsibility | Layer | Why it belongs there |
|
|
||||||
|------------------------|-------|----------------------|
|
|
||||||
| Playback command dispatch (adapter routing: native vs HTML5) | Frontend — `src/lib/player/` **only** | Presentation-layer plumbing, but must be centralised: the facade picks between the native backend and the HTML5 `<video>` adapter. A caller bypassing it silently skips that routing. |
|
|
||||||
| Playback *authority* (position, pause, rate, track changes) | **Rust / the player** | Unchanged. The player is authoritative; UI is a consumer. This spec does not touch that direction. |
|
|
||||||
| Queue mutation commands | Frontend facade → Rust | Rust owns queue state; the facade is the single call path to it. |
|
|
||||||
| Player settings CRUD (EQ, video, autoplay, cache) | Frontend, **outside** the facade | Configuration, not playback control — read/written on a settings page with no adapter routing. Explicitly carved out below. |
|
|
||||||
| Backend→frontend event handling | `playerEvents.ts` | Already correct. It is the facade's own plumbing, not a bypassing consumer. |
|
|
||||||
|
|
||||||
No Jellyfin taxonomy is involved, so no boundary-leak risk.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### 1. Narrow the principle to what it actually means
|
|
||||||
|
|
||||||
Amend CLAUDE.md and [02-svelte-frontend.md](../architecture/02-svelte-frontend.md):
|
|
||||||
|
|
||||||
> **Unified player boundary.** UI controls **playback** — transport, queue
|
|
||||||
> mutation, track selection, playback initiation — *only* through
|
|
||||||
> `playerController`. Player **configuration** commands (`player_*_settings`,
|
|
||||||
> `player_configure_jellyfin`, `player_*_cache_config`, `player_preload_upcoming`)
|
|
||||||
> are ordinary IPC and may be called directly from settings surfaces.
|
|
||||||
|
|
||||||
This is a clarification, not a relaxation: it makes group (c) explicitly fine so
|
|
||||||
that a violation count means something. A rule with 52 nominal violations, most
|
|
||||||
of them acceptable, provides no signal.
|
|
||||||
|
|
||||||
### 2. Fill the facade gaps (group b)
|
|
||||||
|
|
||||||
Add to `playerController`, each a thin pass-through preserving current
|
|
||||||
behaviour:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
playQueue, getQueue, getStatus,
|
|
||||||
enterBackgroundAudio, exitBackgroundAudio,
|
|
||||||
setSleepTimer, cancelSleepTimer,
|
|
||||||
playNextEpisode, cancelAutoplayCountdown,
|
|
||||||
```
|
|
||||||
|
|
||||||
Do this **first** — group (a) cannot be fully migrated while callers still need
|
|
||||||
a direct import for a neighbouring call, and a file that imports `commands` for
|
|
||||||
one reason will keep using it for others.
|
|
||||||
|
|
||||||
### 3. Migrate group (a)
|
|
||||||
|
|
||||||
Mechanical: replace `commands.playerX(...)` with `playerController.x(...)`.
|
|
||||||
Highest-value first: `queue.ts` (10 sites, all direct facade equivalents), then
|
|
||||||
`player/[id]/+page.svelte`, `VideoPlayer.svelte`, `sleepTimer.ts`,
|
|
||||||
`playbackMode.ts`, `library/[id]/+page.svelte`.
|
|
||||||
|
|
||||||
Two sites need care rather than substitution:
|
|
||||||
|
|
||||||
- **`playerEvents.ts`** (`playerOnPlaybackEnded`, `playerStop` in the error
|
|
||||||
path). This module *is* the facade's event plumbing — the counterpart to
|
|
||||||
`index.ts`, inside the boundary conceptually though not by directory. Treat
|
|
||||||
`src/lib/services/playerEvents.ts` as **inside** the boundary and exempt it,
|
|
||||||
rather than making it call the facade that calls back into it. Record this in
|
|
||||||
the lint config with the reason.
|
|
||||||
- **`VideoPlayer.svelte`** — registers its own adapter via `setActiveAdapter`.
|
|
||||||
Its `playerStop`/`playerPlayItem` calls interact with adapter lifecycle, and
|
|
||||||
CLAUDE.md's gotcha ("no lifecycle calls after an `await` in `onMount`") applies.
|
|
||||||
Migrate this file **last and on its own**, so an Android seek regression is
|
|
||||||
bisectable to one commit.
|
|
||||||
|
|
||||||
### 4. Add the lint rule (the part that makes it stick)
|
|
||||||
|
|
||||||
The audit's finding was that principles with working checks held up and
|
|
||||||
principles without them drifted. This principle has no check. Add
|
|
||||||
`scripts/check-player-boundary.sh`, wired as `bun run check:player-boundary` and
|
|
||||||
into `test-all.sh`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# Playback-control commands that MUST go through the facade.
|
|
||||||
CONTROL='player(Play|Pause|Toggle|Stop|Seek|Next|Previous|SkipTo|ToggleShuffle|CycleRepeat|RemoveFromQueue|MoveInQueue|SetVolume|ToggleMute|SetSubtitleTrack|SeekVideo|SwitchAudioTrack|PlayTracks|PlayAlbumTrack|PlayItem|PlayQueue|AddTrackById|AddTracksByIds|GetQueue|GetStatus|EnterBackgroundAudio|ExitBackgroundAudio|SetSleepTimer|CancelSleepTimer|PlayNextEpisode|CancelAutoplayCountdown|OnPlaybackEnded)'
|
|
||||||
|
|
||||||
# Inside the boundary: the facade and its event plumbing.
|
|
||||||
EXEMPT='^src/lib/player/|^src/lib/services/playerEvents\.ts$'
|
|
||||||
```
|
|
||||||
|
|
||||||
Flag `commands.$CONTROL` in non-test `src/` files outside `EXEMPT`. Config
|
|
||||||
commands are deliberately absent from the list, matching §1 — so the check
|
|
||||||
encodes the narrowed rule rather than the aspirational one.
|
|
||||||
|
|
||||||
An ESLint `no-restricted-syntax` rule would give better editor feedback, but the
|
|
||||||
project has no ESLint config; a shell check matches the existing
|
|
||||||
`check:boundary` precedent and adds no dependency.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- Changing playback *behaviour* — pure refactor.
|
|
||||||
- The one-directional state principle (audited clean; UI reads from facade
|
|
||||||
stores only).
|
|
||||||
- Moving settings CRUD behind the facade (§1 explicitly carves it out).
|
|
||||||
- Introducing ESLint.
|
|
||||||
- Refactoring `VideoPlayer.svelte`'s 2079 lines generally, beyond its facade
|
|
||||||
call sites.
|
|
||||||
- The `commands.player*` calls **inside** `src/lib/player/` — that is the
|
|
||||||
facade doing its job.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [ ] `playerController` exposes the group-(b) methods listed in §2.
|
|
||||||
- [ ] `grep -rn "commands\.player" src/ --include='*.ts' --include='*.svelte' | grep -v '^src/lib/player/' | grep -v 'playerEvents\.ts' | grep -v '\.test\.' | grep -v bindings.ts`
|
|
||||||
returns **only** configuration commands per §1 — no transport, queue, or
|
|
||||||
playback-initiation call.
|
|
||||||
- [ ] `queue.ts` no longer imports `commands` from bindings.
|
|
||||||
- [ ] `bun run check:player-boundary` exists, is wired into `test-all.sh`, and
|
|
||||||
passes.
|
|
||||||
- [ ] The check **fails** when a `commands.playerStop()` is added to a non-exempt
|
|
||||||
file — verify explicitly, as with the other gates in this batch.
|
|
||||||
- [ ] The check does **not** fail on `commands.playerSetAudioSettings()` in
|
|
||||||
`settings/+page.svelte` (the §1 carve-out works).
|
|
||||||
- [ ] CLAUDE.md and `02-svelte-frontend.md` carry the narrowed wording, including
|
|
||||||
the config carve-out and the `playerEvents.ts` exemption with its reason.
|
|
||||||
- [ ] **No behavioural change**: audio and video playback, queue reorder,
|
|
||||||
shuffle/repeat, sleep timer, background audio, and autoplay all behave as
|
|
||||||
before on **both Linux and Android**.
|
|
||||||
- [ ] Android seek and `onMount` lifecycle still correct after the
|
|
||||||
`VideoPlayer.svelte` migration (the known-fragile path).
|
|
||||||
- [ ] `bun run check` and `bun run test` pass.
|
|
||||||
- [ ] `bun run check:boundary` passes.
|
|
||||||
- [ ] Changed code carries `// TRACES:` comments.
|
|
||||||
- [ ] No Rust change, so no `bindings.ts` regeneration.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
**Frontend** (`bun run test`):
|
|
||||||
- Extend the existing facade tests to cover each new group-(b) method: it
|
|
||||||
forwards to the right command with the right arguments, and routes to the
|
|
||||||
active adapter where applicable.
|
|
||||||
- `queue.ts` tests: assert calls land on `playerController`, not `commands`. Mock
|
|
||||||
the facade — a test that mocks `commands` would pass either way and guard
|
|
||||||
nothing.
|
|
||||||
- Keep `tauriIntegration.test.ts` and the other IPC param-naming tests green;
|
|
||||||
they cover the camelCase rule this refactor must not disturb.
|
|
||||||
|
|
||||||
**Manual** (no automated coverage for these paths):
|
|
||||||
- Linux: play/pause/seek/next/prev, queue reorder, shuffle, repeat, sleep timer,
|
|
||||||
transcoded video (HLS), background audio enter/exit.
|
|
||||||
- Android: the same, plus lockscreen/MediaSession controls, and **seek after
|
|
||||||
entering the player** — the specific regression CLAUDE.md warns about.
|
|
||||||
|
|
||||||
Because this is a pure refactor, the strongest signal is that no test *changes
|
|
||||||
expectation*. A test needing its assertions rewritten means behaviour moved —
|
|
||||||
investigate rather than update it.
|
|
||||||
|
|
||||||
## TRACES
|
|
||||||
|
|
||||||
Allocate in `requirements.md`:
|
|
||||||
|
|
||||||
- **DR-095** — "UI playback control is routed exclusively through the
|
|
||||||
`playerController` facade (`src/lib/player/`), with `playerEvents.ts` inside
|
|
||||||
the boundary as its event plumbing and player *configuration* commands
|
|
||||||
explicitly outside it; enforced by `scripts/check-player-boundary.sh`."
|
|
||||||
Category: Player. Traces to UR-005. Status: Done on merge.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/lib/player/index.ts
|
|
||||||
// TRACES: UR-005 | DR-095
|
|
||||||
```
|
|
||||||
|
|
||||||
New facade tests take `@req-test: UT-089` onward (next free UT is **UT-089**;
|
|
||||||
coordinate if landing alongside the sibling specs, which draw from the same
|
|
||||||
pool).
|
|
||||||
|
|
||||||
## Notes for the implementer
|
|
||||||
|
|
||||||
- A parallel Claude session may be active in this repo — `git diff` before
|
|
||||||
"repairing" unexpected changes (CLAUDE.md §Gotchas).
|
|
||||||
- **Order matters**: §2 (fill gaps) → §3 (migrate, `VideoPlayer.svelte` last and
|
|
||||||
alone) → §4 (add the check). Adding the check first turns `master` red.
|
|
||||||
- 🔴 **`VideoPlayer.svelte`**: no lifecycle calls after an `await` in `onMount` —
|
|
||||||
it flips to HTML5 mode and breaks Android seek. Do not let a mechanical
|
|
||||||
substitution introduce an `await` before a lifecycle call.
|
|
||||||
- The facade's `requireHandle()` may throw where a raw `commands` call did not.
|
|
||||||
Check each migrated call site's error handling rather than assuming the
|
|
||||||
try/catch still covers the same cases.
|
|
||||||
- `playbackMode.ts` interacts with remote-mode routing (`play_on_session` vs
|
|
||||||
local MPV). Verify remote casting still works after migrating its
|
|
||||||
`playerPlayTracks` call.
|
|
||||||
- This spec is deliberately the *lowest* priority of the audit batch: it is the
|
|
||||||
largest diff and the only one carrying real regression risk, while the
|
|
||||||
traceability gate is a few lines and restores a dead safety net.
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
# Spec: Two-path media — selectable playback bitrate, independent whole-file download
|
|
||||||
|
|
||||||
**Status:** Partially implemented. Landed: the cache/download unification
|
|
||||||
(DR-126, DR-127 — a cache entry *is* a `downloads` row with a shorter life, and
|
|
||||||
eviction only reclaims the temporary tier), local playback of downloaded media
|
|
||||||
(DR-128), and the one-path/one-row invariants that followed (DR-133 … DR-138).
|
|
||||||
DR-123 is in progress. Still open: the read-through capture itself — DR-122,
|
|
||||||
DR-124, DR-125.
|
|
||||||
|
|
||||||
**DR-121 has shipped and left this spec.** The player quality selector, the
|
|
||||||
per-playback bitrate ceiling, and the backend-owned stream decision it needed
|
|
||||||
were built as *backend-owned stream selection* (DR-225 … DR-228) and are
|
|
||||||
described in
|
|
||||||
[01-rust-backend.md](../architecture/01-rust-backend.md#stream-selection) and
|
|
||||||
[03-data-flow.md](../architecture/03-data-flow.md#video-stream-selection-flow).
|
|
||||||
The settings-level ceiling (DR-162) is the same section. What remains here is the
|
|
||||||
*capture* half only — this spec no longer specifies anything about choosing a
|
|
||||||
bitrate.
|
|
||||||
|
|
||||||
**Requirements:** UR-070, UR-071 → DR-122, DR-123, DR-124, DR-125; IR-032
|
|
||||||
**Related:** the locally-indexed search and downloaded-browse work, both
|
|
||||||
shipped — see
|
|
||||||
[03-data-flow.md](../architecture/03-data-flow.md) and
|
|
||||||
[06-downloads-and-offline.md](../architecture/06-downloads-and-offline.md)
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Two things that are today tangled become explicitly separate:
|
|
||||||
|
|
||||||
- **The playback path** streams at a bitrate the viewer can change from the
|
|
||||||
player. It is ephemeral and its rendition is volatile.
|
|
||||||
- **The download path** fetches the whole file at one canonical quality, in the
|
|
||||||
background, independently of whatever playback is doing.
|
|
||||||
|
|
||||||
Bytes fetched for playback are kept **only** when the playback rendition happens
|
|
||||||
to be the same artifact the download path would produce — i.e. direct play.
|
|
||||||
Otherwise playback bytes are discarded and the download path does its own fetch.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
The appealing version of this — "stream and download at once, switch when enough
|
|
||||||
has arrived" — breaks the moment the viewer can change bitrate. A capture taken
|
|
||||||
while the rendition changes underneath it is a splice of two encodings: not a
|
|
||||||
playable file, and not something that can be honestly recorded as a download.
|
|
||||||
Once bitrate is selectable, one stream cannot serve both jobs.
|
|
||||||
|
|
||||||
Separating the paths also removes the thing that made the original idea
|
|
||||||
expensive: there is no mid-playback source swap to engineer, because the download
|
|
||||||
never has to take over the live session. It lands on disk and is used at the next
|
|
||||||
natural boundary — next episode, or next time the item is played.
|
|
||||||
|
|
||||||
What exists already and is *not* this: `SmartCache` predictively downloads *other*
|
|
||||||
items, `player_preload_upcoming` warms the next one, and
|
|
||||||
`refresh_queue_local_sources` swaps queue entries to local at boundaries. All of
|
|
||||||
it concerns items you are not currently playing.
|
|
||||||
|
|
||||||
## Layer assignment
|
|
||||||
|
|
||||||
| Logic / responsibility | Layer | Why it belongs there |
|
|
||||||
|------------------------|-------|----------------------|
|
|
||||||
| Available bitrate options for an item | **Rust** | Derived from Jellyfin's media sources and playback-info negotiation; changes with the API. |
|
|
||||||
| Mapping a chosen bitrate to transcode parameters | **Rust** | Domain vocabulary. `get_video_download_url` already owns the quality→params mapping; playback must reuse it, not restate it. |
|
|
||||||
| Deciding whether playback bytes are keepable (direct play vs transcode) | **Rust** | Depends on the negotiated session. |
|
|
||||||
| Canonical download quality | **Rust** | Policy over domain data. |
|
|
||||||
| Cache eviction, storage budget, sparse-range bookkeeping | **Rust** | Storage policy. |
|
|
||||||
| Promotion to a `downloads` row, and what invalidates a cache entry | **Rust** | Domain state. |
|
|
||||||
| Rendering the quality selector; remembering the last choice | **Frontend** | Presentation and a view preference. The *list* comes from Rust. |
|
|
||||||
| WiFi-only / opt-in toggles | **Frontend collects, Rust enforces** | The control is UI; the gate must hold even if the UI never calls. |
|
|
||||||
|
|
||||||
Borderline, recorded: the **default** playback bitrate could look like a user
|
|
||||||
preference (frontend). It goes to Rust because it must be reconcilable with what
|
|
||||||
the server can actually produce for a given media source — a preference the
|
|
||||||
backend has to validate is not a preference the frontend can own alone. The
|
|
||||||
frontend stores the user's *choice*; Rust decides what that choice resolves to.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### DR-121 — moved out (shipped)
|
|
||||||
|
|
||||||
Bitrate selection in the player shipped as DR-225 … DR-228; see
|
|
||||||
[01-rust-backend.md](../architecture/01-rust-backend.md#stream-selection).
|
|
||||||
|
|
||||||
The one constraint here that the capture work still has to respect: a quality
|
|
||||||
change re-negotiates **within HLS**. Returning a progressive `stream.mp4` for a
|
|
||||||
transcode means playback never starts, because the server encodes the whole file
|
|
||||||
before serving a byte (DR-140). That is why DR-122 below abandons a capture on a
|
|
||||||
quality change rather than trying to splice one.
|
|
||||||
|
|
||||||
### DR-122 — The playback path is ephemeral
|
|
||||||
|
|
||||||
Playback bytes are not persisted unless DR-124 says they are keepable. No partial
|
|
||||||
capture is ever retained across a quality change: on change, any in-flight capture
|
|
||||||
for that session is abandoned and its partial file deleted.
|
|
||||||
|
|
||||||
### DR-123 — The download path is independent
|
|
||||||
|
|
||||||
Downloading the whole file is a separate operation through the existing download
|
|
||||||
manager, at one canonical quality (default `original`, the direct static copy),
|
|
||||||
using `/Videos/{id}/stream.mp4` — progressive and Range-capable, which is what
|
|
||||||
the resumable download worker relies on. It is unaffected by what playback is
|
|
||||||
doing, and playback is unaffected by it.
|
|
||||||
|
|
||||||
Once complete it becomes an ordinary download row, so everything already built on
|
|
||||||
top of downloads — offline browsing, `refresh_queue_local_sources`, the Downloads
|
|
||||||
page — picks it up with no further work.
|
|
||||||
|
|
||||||
**Prerequisite:** downloaded *video* is currently never played locally.
|
|
||||||
`repository_get_video_stream_url` goes straight to the online repo and
|
|
||||||
[player/[id]/+page.svelte:316](../../src/routes/player/[id]/+page.svelte#L316)
|
|
||||||
calls it with no local check — so a completed video download is still streamed.
|
|
||||||
This must be fixed or the whole feature is invisible for video.
|
|
||||||
|
|
||||||
### DR-124 — Keep playback bytes only when they *are* the download
|
|
||||||
|
|
||||||
Capture is enabled only where the played bytes and the canonical download artifact
|
|
||||||
are the same thing — a **direct-play** session. Then:
|
|
||||||
|
|
||||||
| Path | Mechanism |
|
|
||||||
|---|---|
|
|
||||||
| Android / ExoPlayer | `SimpleCache` + `CacheDataSource`, keyed by item id **and** media-source id so renditions never collide. LRU evictor sharing the existing smart-cache budget — not a second budget over the same disk. |
|
|
||||||
| Linux audio / MPV | `stream-record`, set through the existing `set_property` plumbing. |
|
|
||||||
| Linux video (HLS transcode) | **Not captured.** Segments are not a file; assembling one needs ffmpeg, which is not a dependency and which CI is forbidden from installing at job time. The download path (DR-123) covers this case instead. |
|
|
||||||
|
|
||||||
Two abandonment rules, both of which must delete the partial rather than promote
|
|
||||||
it:
|
|
||||||
|
|
||||||
- **Seek during an mpv capture.** `stream-record` is documented as intended for
|
|
||||||
linear streams; seeking breaks the recording. Straight-through listening
|
|
||||||
captures, scrubbing does not.
|
|
||||||
- **Any quality change** (DR-122).
|
|
||||||
|
|
||||||
### DR-125 — Promotion, rendition, and invalidation
|
|
||||||
|
|
||||||
A capture is promoted to a `downloads` row (`status = 'completed'`) only when it
|
|
||||||
covers the whole resource. Partial captures stay cache and remain evictable.
|
|
||||||
|
|
||||||
A new `downloads.source_rendition` column records the negotiated
|
|
||||||
quality/container/codec of whatever produced the bytes; `NULL` for rows fetched by
|
|
||||||
the existing paths, which are always `original`. This is what makes an "upgrade to
|
|
||||||
original" action possible later, and what stops a 720p capture and a 4K download
|
|
||||||
being indistinguishable rows.
|
|
||||||
|
|
||||||
**Invalidation.** A quality change never touches a file that already exists —
|
|
||||||
neither a permanent download nor a completed temporary one. Both remain valid
|
|
||||||
copies of the rendition they hold, and deleting either would throw away bytes
|
|
||||||
already paid for.
|
|
||||||
|
|
||||||
What a quality change *does* invalidate is an **in-flight** capture or background
|
|
||||||
download of cached media: it is abandoned and restarted at the newly chosen
|
|
||||||
quality, because a capture spanning a rendition change is a splice of two
|
|
||||||
encodings rather than a playable file (DR-122).
|
|
||||||
|
|
||||||
So the rule is about *ongoing* work, not stored files. Nothing in this spec
|
|
||||||
deletes user data.
|
|
||||||
|
|
||||||
### Gating
|
|
||||||
|
|
||||||
Capture and background download obey the existing WiFi-only gate and storage
|
|
||||||
budget, and are off unless opted in. Enforcement is in Rust.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- **Mid-playback switch onto a completing download.** Two independent paths make
|
|
||||||
it unnecessary; the download is used from the next boundary.
|
|
||||||
- **Backfilling the unplayed remainder of a capture.** Watch 40 minutes and you
|
|
||||||
have 40 minutes; completing it needs sparse-range bookkeeping and a resumable
|
|
||||||
tail fetch. The DR-123 download path already produces a complete file, which is
|
|
||||||
the reason this can wait.
|
|
||||||
- **Bundling ffmpeg** to make transcoded video capturable. Real option, large
|
|
||||||
packaging decision, its own proposal.
|
|
||||||
- **Routing Linux video playback through `stream.mp4`.** Regresses a documented,
|
|
||||||
hard-won fix.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [ ] The player offers the qualities Rust reports, and changing one resumes at
|
|
||||||
the same position with audio/subtitle selection preserved.
|
|
||||||
- [ ] A quality change abandons any in-flight capture and leaves no partial file.
|
|
||||||
- [ ] A quality change never deletes a `downloads` row.
|
|
||||||
- [ ] A completed background download of a video is *played from disk* on the next
|
|
||||||
play (the DR-123 prerequisite).
|
|
||||||
- [ ] A direct-play session played start-to-finish leaves a complete local file
|
|
||||||
with no second fetch; replaying it fetches no media bytes.
|
|
||||||
- [ ] Seeking during an mpv capture abandons it; no truncated file is promoted.
|
|
||||||
- [ ] A transcoded Linux video session is never captured, and never partially
|
|
||||||
promoted.
|
|
||||||
- [ ] Promoted rows record their rendition; existing paths still record
|
|
||||||
`NULL`/`original`.
|
|
||||||
- [ ] Gates hold with the setting off *and* with the frontend never sending it.
|
|
||||||
- [ ] Eviction cannot delete bytes backing a promoted download row.
|
|
||||||
- [ ] `bun run check`, `bun run test`, `cargo fmt`, `cargo clippy`,
|
|
||||||
`bun run test:rust`, `bun run check:boundary` pass; `bindings.ts`
|
|
||||||
regenerated if Rust types changed.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Rust, table-driven and pure where possible: quality→params resolution shared with
|
|
||||||
the download path; keepability (direct play vs transcode vs gate off); promotion
|
|
||||||
(complete → promoted, partial → not, seek-abandoned → not, quality-changed → not);
|
|
||||||
invalidation (evicts cache, never a download row); rendition round-trip.
|
|
||||||
|
|
||||||
Android: instrumented — a played direct-play item yields cache entries, and a
|
|
||||||
replay issues no media network request.
|
|
||||||
|
|
||||||
Frontend: the quality list renders from backend data with no item-type or
|
|
||||||
codec taxonomy in `src/`; the selector's remembered choice is a view preference.
|
|
||||||
|
|
||||||
## TRACES
|
|
||||||
|
|
||||||
| Piece | Tag |
|
|
||||||
|---|---|
|
|
||||||
| Ephemeral playback / capture abandonment | `// TRACES: UR-070 \| DR-122` |
|
|
||||||
| Independent whole-file download + local video playback fix | `// TRACES: UR-071 \| DR-123, IR-032` |
|
|
||||||
| ExoPlayer cache / mpv stream-record / keepability | `// TRACES: UR-071 \| DR-124` |
|
|
||||||
| Promotion, `source_rendition`, invalidation | `// TRACES: UR-071 \| DR-125` |
|
|
||||||
|
|
||||||
## Notes for the implementer
|
|
||||||
|
|
||||||
- **A parallel Claude session is active in this repo.** `git diff` before
|
|
||||||
"repairing" anything you did not write.
|
|
||||||
- Do not duplicate the quality→transcode-parameter table. Call the existing one.
|
|
||||||
- Reuse the smart-cache storage budget; two budgets over one disk is how devices
|
|
||||||
fill up.
|
|
||||||
- The `downloads` FK to `items` is relaxed (migration 005) — exercise promotion
|
|
||||||
for an item that was never cached.
|
|
||||||
- Build DR-123's local-playback fix first. Without it nothing in this spec is
|
|
||||||
observable for video.
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
# Spec: Land the scoped-search boundary fix (implementation)
|
|
||||||
|
|
||||||
**Status:** Stage 1 Implemented — Stage 2 (result-side grouping) outstanding
|
|
||||||
**Requirements:** UR-049, UR-050 | DR-063, DR-066, DR-067 (existing — no new IDs)
|
|
||||||
**UX spec:** n/a — zero user-visible change is the point (see Acceptance criteria).
|
|
||||||
**Supersedes / revises:** implements [scoped-search-boundary.md](scoped-search-boundary.md),
|
|
||||||
which specified this fix but was never built. That spec remains the **design
|
|
||||||
authority**; this one is the delivery plan and status correction.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
[scoped-search-boundary.md](scoped-search-boundary.md) diagnosed a domain-taxonomy
|
|
||||||
leak, specified the fix in full detail, and became the justification for the
|
|
||||||
project's boundary rule in CLAUDE.md, the `check:boundary` tripwire, and the
|
|
||||||
spec-review checklist. **The fix was never implemented.** The leak it describes
|
|
||||||
is still live in `main`. This spec exists to close that gap and to correct the
|
|
||||||
record — the codebase currently enforces a rule against a violation it still
|
|
||||||
contains.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
The mapping the rule forbids is present and in use:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// src/lib/utils/searchScope.ts:29-32
|
|
||||||
const SCOPE_ITEM_TYPES: Record<Exclude<SearchScope, "all">, string[]> = {
|
|
||||||
music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
|
|
||||||
movies: ["Movie"],
|
|
||||||
tv: ["Series", "Episode"],
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
This is not dead code. [library.ts:262](../../src/lib/stores/library.ts#L262)
|
|
||||||
calls `scopeItemTypes(scope)` and puts the result straight into
|
|
||||||
`options.includeItemTypes`. Meanwhile there is **no `SearchScope` anywhere in
|
|
||||||
`src-tauri/`**:
|
|
||||||
|
|
||||||
```console
|
|
||||||
$ grep -rn "SearchScope" src-tauri/src --include='*.rs'
|
|
||||||
(no output)
|
|
||||||
```
|
|
||||||
|
|
||||||
Three things make this the highest-value item found in the design-principles
|
|
||||||
audit:
|
|
||||||
|
|
||||||
1. **The rule's own founding incident is unremediated.** CLAUDE.md cites this
|
|
||||||
spec as "the incident this rule came from." A rule whose originating
|
|
||||||
violation is still shipping is not credible.
|
|
||||||
2. **The tripwire cannot see it.** `bun run check:boundary` passes — it greps for
|
|
||||||
a multi-type array literal *at the query site*, and this one is assigned to a
|
|
||||||
named const and dereferenced elsewhere. Broadening the tripwire is specified
|
|
||||||
separately by the tripwire hardening (DR-094, shipped);
|
|
||||||
note that hardening it **without** landing this fix would turn `master` red.
|
|
||||||
3. **The spec's own acceptance criterion fails today.** "Adding a hypothetical
|
|
||||||
new type to a scope requires editing only Rust" — adding a type to the Music
|
|
||||||
scope right now requires editing `searchScope.ts`.
|
|
||||||
|
|
||||||
## Layer assignment
|
|
||||||
|
|
||||||
Unchanged from [scoped-search-boundary.md](scoped-search-boundary.md) §Design;
|
|
||||||
restated so this spec is reviewable on its own.
|
|
||||||
|
|
||||||
| Logic / responsibility | Layer | Why it belongs there |
|
|
||||||
|------------------------|-------|----------------------|
|
|
||||||
| Scope → Jellyfin item types (`music` → `MusicAlbum`, `MusicArtist`, `Audio`, `Playlist`) | **Rust** | Domain vocabulary. Changes if Jellyfin adds/renames an item type — the litmus test's "yes" case. This is the leak being fixed. |
|
|
||||||
| Result item → search group bucketing | **Rust** | Same taxonomy, result side. Classifying a `MediaItem` as a Song vs Album is Jellyfin vocabulary, not layout. |
|
|
||||||
| `All` sends no filter at all (≠ union of enumerated types) | **Rust** | A query-shaping rule with a correctness consequence (Person/folder results would be silently dropped). Belongs with the expansion it qualifies. |
|
|
||||||
| Group display order, labels, reordering, persistence | Frontend | Pure presentation — changes only if the UI is redesigned. Explicitly retained frontend-side. |
|
|
||||||
| `resolveSearchScope(pathname)` — route → initial scope | Frontend | Routing/navigation, no Jellyfin vocabulary. Stays exactly as-is. |
|
|
||||||
| Chip labels (`SCOPE_LABELS`), scope order (`SEARCH_SCOPES`) | Frontend | Display strings over an opaque enum. |
|
|
||||||
| `GROUP_SCOPE` (which group belongs to which scope) | **Delete** | Borderline taxonomy, made redundant: once Rust filters by scope, out-of-scope groups arrive empty and drop via the empty-omit rule. Borderline defaults to Rust; here it defaults to *gone*. |
|
|
||||||
|
|
||||||
The `SearchScope` and `SearchGroupId` **types** come to the frontend from
|
|
||||||
generated `bindings.ts`. Naming an opaque enum variant is not taxonomy; knowing
|
|
||||||
what item types it expands to is.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
**Follow [scoped-search-boundary.md](scoped-search-boundary.md) §Design as
|
|
||||||
written** — `SearchScope` enum + `item_types()` in `repository/types.rs`,
|
|
||||||
`SearchOptions.scope`, `SearchGroupId`/`SearchGroup`/`GroupedSearchResult`,
|
|
||||||
scope-wins precedence, `All` → `None` → no filter. It is not restated here;
|
|
||||||
duplicating it would create two drifting copies of the same design.
|
|
||||||
|
|
||||||
This spec adds only the delivery sequencing that the original left implicit.
|
|
||||||
|
|
||||||
### Staging: land it in two reviewable pieces
|
|
||||||
|
|
||||||
The original bundles the query side and the result side into one change. That is
|
|
||||||
a large diff touching Rust types, `bindings.ts`, the store, and a component, with
|
|
||||||
the `search-event` dual-payload hazard in the middle. Split it:
|
|
||||||
|
|
||||||
**Stage 1 — query side (closes the leak).**
|
|
||||||
`SearchScope` enum, `SearchOptions.scope`, command resolves scope →
|
|
||||||
`include_item_types` in Rust, `library.ts` sends `{ scope }`, delete
|
|
||||||
`SCOPE_ITEM_TYPES` and `scopeItemTypes()`. Result grouping stays as it is.
|
|
||||||
|
|
||||||
After Stage 1 the actual boundary violation is gone and
|
|
||||||
the hardened tripwire (DR-094) can pass.
|
|
||||||
|
|
||||||
**Stage 2 — result side.** `SearchGroupId`/`SearchGroup`/`GroupedSearchResult`,
|
|
||||||
Rust bucketing, both payloads converted, `composeSearchGroups()` shrunk,
|
|
||||||
`GROUP_ITEM_TYPES`/`groupItemTypes()`/`GROUP_SCOPE` deleted.
|
|
||||||
|
|
||||||
Both stages are required for the original spec's acceptance criteria to pass;
|
|
||||||
Stage 1 alone leaves `GROUP_ITEM_TYPES` in the frontend. **Stage 1 is not a
|
|
||||||
stopping point** — it is a review boundary. Do not mark the parent spec
|
|
||||||
Implemented until Stage 2 lands.
|
|
||||||
|
|
||||||
### Stage 1 — delivered (July 2026)
|
|
||||||
|
|
||||||
- `SearchScope` enum + `item_types()` in [repository/types.rs](../../src-tauri/src/repository/types.rs);
|
|
||||||
`All` → `None` → no filter.
|
|
||||||
- `SearchOptions.scope` with `resolve_scope()`; scope wins over
|
|
||||||
`include_item_types`, which stays for the non-search `get_items` callers.
|
|
||||||
- `repository_search` resolves the scope **once, before** the cache/server split,
|
|
||||||
so both phases filter identically.
|
|
||||||
- `SCOPE_ITEM_TYPES` and `scopeItemTypes()` deleted; `searchScope.ts` now
|
|
||||||
re-exports `SearchScope` from the generated bindings instead of a hand-written
|
|
||||||
union.
|
|
||||||
- [library.ts](../../src/lib/stores/library.ts) sends `{ scope }`.
|
|
||||||
- 8 Rust tests (`search_scope_tests`); the frontend suite now asserts the
|
|
||||||
*opaque scope* is sent rather than an item-type list.
|
|
||||||
|
|
||||||
Verified: adding `"AudioBook"` to the Music scope changed **zero** files under
|
|
||||||
`src/` — the criterion that failed before this work.
|
|
||||||
|
|
||||||
**Stage 2 remains open**: `GROUP_ITEM_TYPES` / `groupItemTypes()` (result-side
|
|
||||||
bucketing, single-type-per-group) are still in `searchScope.ts`, and both search
|
|
||||||
payloads still carry a flat `MediaItem[]` rather than `GroupedSearchResult`.
|
|
||||||
|
|
||||||
### 🔴 The `search-event` dual payload (Stage 2)
|
|
||||||
|
|
||||||
The original flags this as "the single largest part of the change and the
|
|
||||||
easiest to half-do." Restating because it is the one thing that silently breaks:
|
|
||||||
search resolves **twice** — the command returns instant cache results, then the
|
|
||||||
merged cache+server union arrives via `search-event`. Both payloads must carry
|
|
||||||
`GroupedSearchResult`. Convert one and the UI flickers between shapes as server
|
|
||||||
results land.
|
|
||||||
|
|
||||||
Write the failing test for the *event* payload first — the command return is the
|
|
||||||
obvious half, the event is the half that gets forgotten.
|
|
||||||
|
|
||||||
### Note on `SearchOptions.scope` and specta
|
|
||||||
|
|
||||||
`SearchOptions` is already `#[serde(rename_all = "camelCase")]` with
|
|
||||||
`skip_serializing_if = "Option::is_none"`. Add `scope: Option<SearchScope>`
|
|
||||||
following that pattern so `All`/absent omits the key. Regenerate `bindings.ts`
|
|
||||||
— `SearchOptions` there is currently
|
|
||||||
`{ limit?, includeItemTypes?, searchTerm? }` and must gain `scope?`. Never
|
|
||||||
hand-edit it.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- Redesigning anything in [scoped-search-boundary.md](scoped-search-boundary.md).
|
|
||||||
If implementation shows the design wrong, revise **that** spec, don't fork it.
|
|
||||||
- Online/offline `include_item_types` **filtering** — already correct; only the
|
|
||||||
source of the type list moves.
|
|
||||||
- Ranking within or across groups (DR-090 territory).
|
|
||||||
- Chip UX, scope persistence, group-order persistence — unchanged.
|
|
||||||
- The two lesser type-set sites in `DownloadedBrowse.svelte` and
|
|
||||||
`GenericMediaListPage.svelte`, handled in
|
|
||||||
the hardened tripwire (DR-094, see `scripts/check-frontend-boundary.sh`).
|
|
||||||
- Broadening the tripwire itself — same sibling spec.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
Inherits every criterion from [scoped-search-boundary.md](scoped-search-boundary.md)
|
|
||||||
§Acceptance criteria. Additionally:
|
|
||||||
|
|
||||||
- [ ] `grep -rn "SearchScope" src-tauri/src --include='*.rs'` returns matches —
|
|
||||||
the enum exists in Rust (it does not today).
|
|
||||||
- [ ] `grep -n "SCOPE_ITEM_TYPES\|scopeItemTypes\|GROUP_ITEM_TYPES\|groupItemTypes" src/lib/utils/searchScope.ts`
|
|
||||||
returns nothing.
|
|
||||||
- [ ] `grep -rn "scopeItemTypes" src/` returns nothing — including the
|
|
||||||
`library.ts` import and call site.
|
|
||||||
- [ ] `SearchOptions` in `bindings.ts` includes `scope`; regenerated, not
|
|
||||||
hand-edited.
|
|
||||||
- [ ] **Behaviour is byte-identical for the user**: same scoping, same groups,
|
|
||||||
same order, same empty-group omission, offline included. This spec is a
|
|
||||||
pure refactor — any visible change is a defect.
|
|
||||||
- [ ] `All` scope sends no `includeItemTypes` (asserted in a Rust test, not by
|
|
||||||
inspection).
|
|
||||||
- [ ] Adding a type to the Music scope requires editing **only** Rust —
|
|
||||||
demonstrate by making the edit and confirming no `src/` file changes.
|
|
||||||
- [ ] `scoped-search-boundary.md` status flips to **Implemented**, and
|
|
||||||
`scoped-search.md`'s "frontend only, no Rust changes" framing gets a
|
|
||||||
banner pointing at the corrected design.
|
|
||||||
- [ ] `bun run check` and `bun run test` pass.
|
|
||||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
|
||||||
- [ ] `bun run check:boundary` passes.
|
|
||||||
- [ ] Changed code carries `// TRACES:` comments (IDs below).
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Follow [scoped-search-boundary.md](scoped-search-boundary.md) §Testing. Emphases:
|
|
||||||
|
|
||||||
**Rust** (`cargo test`):
|
|
||||||
- `SearchScope::item_types()` per scope; `All` → `None`.
|
|
||||||
- Scope resolution happens **before** the online/offline split, so both paths
|
|
||||||
get the same filter — a regression here is invisible until someone searches
|
|
||||||
offline.
|
|
||||||
- `scope` set + `include_item_types` set → scope wins (the documented
|
|
||||||
precedence; assert it rather than trusting the doc).
|
|
||||||
- Stage 2: mixed `Vec<MediaItem>` buckets correctly; unknown types dropped;
|
|
||||||
canonical group order; **the `search-event` payload is the grouped shape**.
|
|
||||||
|
|
||||||
**Frontend** (`bun run test`):
|
|
||||||
- `resolveSearchScope()` tests in `searchScope.test.ts` must pass **unchanged** —
|
|
||||||
they cover the part that is not moving, and are the regression net proving the
|
|
||||||
refactor didn't disturb routing.
|
|
||||||
- `library.ts` sends `{ scope }` and never `includeItemTypes` for search.
|
|
||||||
- `composeSearchGroups()` over fixture `SearchGroup[]` with no `.type`
|
|
||||||
inspection in the implementation.
|
|
||||||
|
|
||||||
**Offline parity:** run a scoped search with the server unreachable and confirm
|
|
||||||
identical grouping. The offline repository path honours `include_item_types`
|
|
||||||
independently, and this is the case most likely to be missed.
|
|
||||||
|
|
||||||
## TRACES
|
|
||||||
|
|
||||||
No new requirement IDs — this implements existing ones. Retag as the code moves:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// src-tauri/src/repository/types.rs
|
|
||||||
/// TRACES: UR-049 | DR-063
|
|
||||||
pub enum SearchScope { … }
|
|
||||||
```
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/lib/utils/searchScope.ts — keep the file header; it retains
|
|
||||||
// resolveSearchScope + group-order presentation logic.
|
|
||||||
// TRACES: UR-049, UR-050 | DR-063, DR-066, DR-067
|
|
||||||
```
|
|
||||||
|
|
||||||
Update DR-063's text in `requirements.md` to state that scope expansion is owned
|
|
||||||
by Rust, so the requirement stops describing the leaked design. New Rust tests
|
|
||||||
take `@req-test: UT-089` onward (next free UT is **UT-089**).
|
|
||||||
|
|
||||||
## Notes for the implementer
|
|
||||||
|
|
||||||
- A parallel Claude session may be active in this repo — `git diff` before
|
|
||||||
"repairing" unexpected changes (CLAUDE.md §Gotchas).
|
|
||||||
- **Read [scoped-search-boundary.md](scoped-search-boundary.md) first.** This
|
|
||||||
spec is deliberately thin on design; that one is the authority.
|
|
||||||
- Sequence with the sibling specs: **Stage 1 here → then
|
|
||||||
the hardened tripwire (DR-094)**. Hardening
|
|
||||||
the tripwire first turns `master` red on a known-unfixed violation.
|
|
||||||
- `git log --oneline -- docs/specs/scoped-search-boundary.md` is worth a look
|
|
||||||
before starting — understanding why the fix stalled may surface a constraint
|
|
||||||
the spec didn't record.
|
|
||||||
- The user-visible-change count for this spec is zero. If QA reports a
|
|
||||||
difference in search results, that is a bug in the refactor, not an
|
|
||||||
improvement.
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
# Spec: Move search scope taxonomy behind the Rust boundary
|
|
||||||
|
|
||||||
**Status:** Design authority — **Stage 1 implemented**, Stage 2 outstanding.
|
|
||||||
The scope→item-type mapping now lives in Rust (`SearchScope::item_types()` in
|
|
||||||
`repository/types.rs`, DR-063 … DR-067). The *result-side* grouping table
|
|
||||||
(`GROUP_ITEM_TYPES` in `src/lib/utils/searchScope.ts`) is still in the
|
|
||||||
frontend, and `check:boundary` does not match its shape. Delivery status and
|
|
||||||
the remaining work live in
|
|
||||||
[scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md);
|
|
||||||
this spec remains the design authority.
|
|
||||||
**Scope:** Rust + Frontend. **Revises a decision in
|
|
||||||
[scoped-search.md](scoped-search.md).**
|
|
||||||
**Requirements:** UR-049, UR-050 (existing) → new DRs for the boundary move
|
|
||||||
(allocate on implementation; suggested DR-063/DR-065/DR-067 revisions plus one
|
|
||||||
new DR for the grouped result shape — see [requirements.md](../requirements.md)).
|
|
||||||
**UX spec:** unchanged — [ux-flows.md §6](../ux-flows.md). This is a pure
|
|
||||||
architecture/boundary change with **no user-visible behaviour difference**.
|
|
||||||
|
|
||||||
## Why this spec exists
|
|
||||||
|
|
||||||
[scoped-search.md](scoped-search.md) shipped scoped search as "frontend only, no
|
|
||||||
Rust changes." That was the smallest wiring change, and it worked — but it left
|
|
||||||
**Jellyfin's item-type taxonomy encoded in the presentation layer**, which
|
|
||||||
violates the project's core boundary rule ("Svelte frontend — presentation
|
|
||||||
only"; all business logic in Rust — see [CLAUDE.md](../../CLAUDE.md) and
|
|
||||||
[architecture/02-svelte-frontend.md](../architecture/02-svelte-frontend.md)).
|
|
||||||
|
|
||||||
The offending knowledge lives in
|
|
||||||
[searchScope.ts](../../src/lib/utils/searchScope.ts):
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const SCOPE_ITEM_TYPES = {
|
|
||||||
music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
|
|
||||||
movies: ["Movie"],
|
|
||||||
tv: ["Series", "Episode"],
|
|
||||||
};
|
|
||||||
const GROUP_ITEM_TYPES = {
|
|
||||||
songs: ["Audio"], albums: ["MusicAlbum"], artists: ["MusicArtist"],
|
|
||||||
movies: ["Movie"], tvShows: ["Series", "Episode"],
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
This is a **domain definition** — "what the category *Music* means in Jellyfin's
|
|
||||||
vocabulary" — expressed twice, in the wrong layer. The concrete failure it
|
|
||||||
creates: the day the backend starts returning a type the frontend never
|
|
||||||
enumerated (e.g. `MusicVideo`, or Jellyfin renaming a kind), search silently
|
|
||||||
drops it from both the query filter and the result buckets, and nothing in the
|
|
||||||
Rust layer — the actual authority on Jellyfin's API — can correct it. Two
|
|
||||||
sources of truth that will drift.
|
|
||||||
|
|
||||||
**This must be fixed while the feature is uncommitted**, before the leak ships
|
|
||||||
baked into a released wire contract.
|
|
||||||
|
|
||||||
### What is *not* a leak (leave it alone)
|
|
||||||
|
|
||||||
Single concrete-type list pages are **not** business logic and stay as-is:
|
|
||||||
|
|
||||||
- `music.ts` → `["MusicAlbum"]` / `["Playlist"]`, `movies.ts` → `["Movie"]`,
|
|
||||||
`tv.ts` → `["Series"]`
|
|
||||||
- `GenericMediaListPage.svelte` → `[config.itemType]`
|
|
||||||
- `ArtistDetailView`, `RelatedItemsSection`, `AddToPlaylistModal`,
|
|
||||||
`PersonDetailView`
|
|
||||||
|
|
||||||
"This page shows albums" is a legitimate presentation choice expressed through a
|
|
||||||
generic `getItems(parentId, { includeItemTypes })` API. Only the **search scope
|
|
||||||
taxonomy** (a semantic category → many types, defined once and reused) crosses
|
|
||||||
the line. Do **not** invent a backend enum for every list page — that is
|
|
||||||
over-abstraction, not cleaner separation.
|
|
||||||
|
|
||||||
## The boundary rule after this change
|
|
||||||
|
|
||||||
> The frontend never names a Jellyfin item type **in connection with search.**
|
|
||||||
> It sends an opaque `scope`, and receives results already sorted into labelled
|
|
||||||
> groups. The frontend owns only **group order** (presentation) and
|
|
||||||
> **rendering**.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### Rust owns scope → item-types (query side)
|
|
||||||
|
|
||||||
Add an opaque enum that crosses IPC, and move the expansion table into Rust:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// repository/types.rs
|
|
||||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub enum SearchScope { All, Music, Movies, Tv }
|
|
||||||
|
|
||||||
impl SearchScope {
|
|
||||||
/// The Jellyfin item types this scope requests, or None for `All`
|
|
||||||
/// (which must send NO includeItemTypes — see below).
|
|
||||||
pub fn item_types(self) -> Option<Vec<String>> {
|
|
||||||
match self {
|
|
||||||
SearchScope::All => None,
|
|
||||||
SearchScope::Music => Some(vec!["MusicAlbum", "MusicArtist", "Audio", "Playlist"]
|
|
||||||
.into_iter().map(String::from).collect()),
|
|
||||||
SearchScope::Movies => Some(vec!["Movie".into()]),
|
|
||||||
SearchScope::Tv => Some(vec!["Series".into(), "Episode".into()]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`SearchOptions` gains `scope` and the search command resolves it into the
|
|
||||||
existing `include_item_types` filter **inside Rust**, before dispatching to the
|
|
||||||
online/offline paths (which already honour `include_item_types` — do not touch
|
|
||||||
their filtering, per [scoped-search.md](scoped-search.md) §Background 2).
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct SearchOptions {
|
|
||||||
pub limit: Option<usize>,
|
|
||||||
pub search_term: Option<String>,
|
|
||||||
pub scope: Option<SearchScope>, // NEW
|
|
||||||
// include_item_types stays for the single-type list-page callers,
|
|
||||||
// but the SEARCH command derives it from `scope` when scope is set.
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Precedence:** if `scope` is set it wins; `include_item_types` remains for the
|
|
||||||
non-search `getItems` callers. Document this so a future reader does not send
|
|
||||||
both.
|
|
||||||
|
|
||||||
**`All` sends no filter.** Preserve the existing invariant: `All` must omit
|
|
||||||
`includeItemTypes` entirely, not send the union of every enumerated type — types
|
|
||||||
nobody listed (Person, folders) would otherwise be filtered out. This is why
|
|
||||||
`item_types()` returns `Option`, and the command must skip the filter on `None`.
|
|
||||||
|
|
||||||
### Rust owns result bucketing (result side)
|
|
||||||
|
|
||||||
Results arrive **pre-grouped**. Rust classifies each returned `MediaItem` into a
|
|
||||||
group by its type — the `GROUP_ITEM_TYPES` knowledge, moved to the authority:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub enum SearchGroupId { Songs, Albums, Artists, Movies, TvShows }
|
|
||||||
|
|
||||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct SearchGroup { pub id: SearchGroupId, pub items: Vec<MediaItem> }
|
|
||||||
|
|
||||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct GroupedSearchResult { pub groups: Vec<SearchGroup> }
|
|
||||||
```
|
|
||||||
|
|
||||||
Rust emits **every** non-empty group it can classify, in a stable canonical
|
|
||||||
order. It does **not** apply the user's ordering or drop out-of-scope groups —
|
|
||||||
those are presentation and stay frontend-side (see below). Items whose type maps
|
|
||||||
to no group are omitted from grouped output (same as today's frontend filter).
|
|
||||||
|
|
||||||
### 🔴 The `search-event` wrinkle — both payloads must change
|
|
||||||
|
|
||||||
Search returns results **twice**: the command resolves with instant local-cache
|
|
||||||
results, then the merged cache+server union arrives later via the `search-event`
|
|
||||||
listener (see [library.ts](../../src/lib/stores/library.ts) `search()` and
|
|
||||||
[architecture/03-data-flow.md](../architecture/03-data-flow.md)). **Both** the
|
|
||||||
command return value **and** the `search-event` payload must carry
|
|
||||||
`GroupedSearchResult`. If only one is converted, the instant results group and
|
|
||||||
the merged ones do not (or vice versa), and the UI flickers between shapes. This
|
|
||||||
is the single largest part of the change and the easiest to half-do.
|
|
||||||
|
|
||||||
### What the frontend keeps (all pure presentation)
|
|
||||||
|
|
||||||
[searchScope.ts](../../src/lib/utils/searchScope.ts) **retains**:
|
|
||||||
|
|
||||||
- `SearchScope` type — now sourced from the generated bindings, mirroring the
|
|
||||||
Rust enum (delete the hand-written union).
|
|
||||||
- `SCOPE_LABELS`, `SEARCH_SCOPES` (chip labels / order).
|
|
||||||
- `resolveSearchScope(pathname)` — route → initial scope. Pure, DOM-free,
|
|
||||||
unit-tested. **Stays exactly as-is.**
|
|
||||||
- `SearchGroupId` (from bindings), `GROUP_LABELS`.
|
|
||||||
- `normalizeGroupOrder`, `groupsForScope`, `moveGroup`, `reorderGroups`,
|
|
||||||
`DEFAULT_GROUP_ORDER` — group-order persistence and reordering, all
|
|
||||||
presentation.
|
|
||||||
|
|
||||||
[searchScope.ts](../../src/lib/utils/searchScope.ts) **loses**:
|
|
||||||
|
|
||||||
- `SCOPE_ITEM_TYPES`, `GROUP_ITEM_TYPES` (moved to Rust).
|
|
||||||
- `scopeItemTypes()`, `groupItemTypes()`.
|
|
||||||
- The `.type`-inspecting body of `composeSearchGroups()`.
|
|
||||||
|
|
||||||
`composeSearchGroups()` shrinks to a **presentation composition over Rust's
|
|
||||||
groups** — no `.type` inspection anywhere:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Take Rust's pre-bucketed groups; drop out-of-scope, sort by saved order,
|
|
||||||
// attach labels, omit empties. No Jellyfin type vocabulary.
|
|
||||||
composeSearchGroups(groups: SearchGroup[], scope, order): DisplayGroup[]
|
|
||||||
```
|
|
||||||
|
|
||||||
`GROUP_SCOPE` (which group belongs to which scope) is a borderline case: it is
|
|
||||||
"is Songs part of the Music scope," arguably taxonomy. But because Rust already
|
|
||||||
filtered the query by scope, out-of-scope groups will simply be **empty** and
|
|
||||||
drop out via the empty-omit rule — so the frontend does not strictly need
|
|
||||||
`GROUP_SCOPE` for correctness once Rust filters. **Recommendation:** delete
|
|
||||||
`GROUP_SCOPE` and rely on empty-omission; if kept for belt-and-suspenders, treat
|
|
||||||
it as a display hint, not authority.
|
|
||||||
|
|
||||||
### Frontend call-site changes
|
|
||||||
|
|
||||||
- [library.ts](../../src/lib/stores/library.ts) `search(query, scope)` sends
|
|
||||||
`{ scope }` in `SearchOptions` instead of computing `includeItemTypes`.
|
|
||||||
Everything else (requestId bump, stale guard, 10s timeout, empty-query clear,
|
|
||||||
event merge) is preserved.
|
|
||||||
- [SearchResults.svelte](../../src/lib/components/search/SearchResults.svelte)
|
|
||||||
consumes `SearchGroup[]` from the store instead of a flat `MediaItem[]` +
|
|
||||||
client-side `composeSearchGroups(results, …)`. The store now holds grouped
|
|
||||||
results.
|
|
||||||
- [search/+page.svelte](../../src/routes/search/+page.svelte) is unchanged in
|
|
||||||
behaviour; only the type it passes to `SearchResults` changes.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- Any change to online/offline `include_item_types` **filtering** — it already
|
|
||||||
works; only the *source* of the type list moves.
|
|
||||||
- Single concrete-type list pages (see "What is not a leak").
|
|
||||||
- Ranking within or across groups.
|
|
||||||
- The UX / chip behaviour / persistence mechanism — all unchanged from
|
|
||||||
[scoped-search.md](scoped-search.md).
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [ ] No Jellyfin item-type string literal (`"MusicAlbum"`, `"Audio"`, …) remains
|
|
||||||
in `searchScope.ts` or any search call path. Verify:
|
|
||||||
`grep -rn '"MusicAlbum"\|"MusicArtist"\|"Audio"\|"Series"\|"Episode"\|"Movie"\|"Playlist"' src/lib/utils/searchScope.ts src/lib/stores/library.ts` returns nothing.
|
|
||||||
- [ ] `SearchScope` and `SearchGroupId` in the frontend come from the generated
|
|
||||||
`bindings.ts`, not hand-written unions.
|
|
||||||
- [ ] Search behaviour is **identical** to today for the user: same scoping, same
|
|
||||||
groups, same order, same empty/out-of-scope omission, offline included.
|
|
||||||
- [ ] Both the command return and the `search-event` payload carry the grouped
|
|
||||||
shape; no shape flicker between instant and merged results.
|
|
||||||
- [ ] `All` scope still sends no `includeItemTypes` (assert in a Rust test).
|
|
||||||
- [ ] Adding a hypothetical new type to a scope requires editing **only** Rust.
|
|
||||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
|
||||||
- [ ] `bun run check` and `bun run test` pass; `bindings.ts` regenerated and
|
|
||||||
committed.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
**Rust** (`src-tauri`, `cargo test`):
|
|
||||||
- `SearchScope::item_types()`: each scope's list, and `All` → `None`.
|
|
||||||
- Search command: `scope: Music` resolves to the four music types on the query;
|
|
||||||
`scope: All` sends no `include_item_types`.
|
|
||||||
- Bucketing: a mixed `Vec<MediaItem>` classifies into the right `SearchGroupId`s;
|
|
||||||
unknown types are dropped; groups come out in canonical order.
|
|
||||||
- The `search-event` payload is the grouped shape (guard the wrinkle).
|
|
||||||
|
|
||||||
**Frontend** (vitest, `src/lib/**/*.test.ts`) — update existing tests:
|
|
||||||
- `librarySearchScope.test.ts` currently asserts `includeItemTypes` on the
|
|
||||||
outgoing options — **rewrite** to assert `scope` is sent instead.
|
|
||||||
- `searchScope.test.ts` — drop `scopeItemTypes`/`groupItemTypes` cases; keep and
|
|
||||||
extend `resolveSearchScope`, order normalize/move/reorder, and the new
|
|
||||||
compose-over-groups (order + empty-omit, no type inspection).
|
|
||||||
- `searchGroupOrder.test.ts` — unchanged.
|
|
||||||
|
|
||||||
## TRACES
|
|
||||||
|
|
||||||
Per [CLAUDE.md](../../CLAUDE.md), tag requirement-implementing code:
|
|
||||||
- `SearchScope` enum + `item_types()` + search command scope resolution:
|
|
||||||
`UR-049 | DR-063` (revised — resolution now Rust-side).
|
|
||||||
- Grouped result shape + bucketing: `UR-050 | DR-067` (revised) + a new DR for
|
|
||||||
the wire shape.
|
|
||||||
- `library.ts` store change: `UR-049 | DR-065` (revised — sends scope not types).
|
|
||||||
|
|
||||||
## Notes for the implementer
|
|
||||||
|
|
||||||
- This spec **revises** [scoped-search.md](scoped-search.md) §Background 2 and
|
|
||||||
§Design "Scope model / Threading scope through the store," which asserted no
|
|
||||||
Rust change. Update that spec's status to note the boundary was moved, or add a
|
|
||||||
banner pointing here — do not leave the two specs contradicting silently.
|
|
||||||
- The IPC camelCase rule applies to the new enums and structs
|
|
||||||
([CLAUDE.md](../../CLAUDE.md)): `#[serde(rename_all = "camelCase")]` on structs;
|
|
||||||
the tagged-enum tag convention if any enum becomes tagged. Add/extend a
|
|
||||||
`tauriIntegration`-style test if a new command is introduced.
|
|
||||||
- Regenerate `bindings.ts` via the tauri-specta build step after changing Rust
|
|
||||||
types; do not hand-edit it.
|
|
||||||
- **Another Claude session may be active in these same files** (per project
|
|
||||||
memory). `git diff` before repairing anything unexpected; these search files
|
|
||||||
are exactly the ones a parallel session touched.
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
# Spec: Context-scoped search with filter chips and configurable group order
|
|
||||||
|
|
||||||
> ⚠️ **Superseded in part by
|
|
||||||
> [scoped-search-boundary.md](scoped-search-boundary.md).** The "frontend only,
|
|
||||||
> no Rust changes" decision below (§Background 2, §Design "Scope model" and
|
|
||||||
> "Threading scope through the store") left Jellyfin's item-type taxonomy in the
|
|
||||||
> presentation layer, which violates the backend/frontend boundary. The taxonomy
|
|
||||||
> is being moved into Rust. The **user-facing behaviour and UX in this spec are
|
|
||||||
> unchanged**; only where the scope→item-type mapping and result bucketing live
|
|
||||||
> changes. Read the boundary spec before touching search code.
|
|
||||||
>
|
|
||||||
> **Progress:** the scope→item-type mapping now lives in Rust
|
|
||||||
> (`SearchScope::item_types()`); the frontend sends an opaque scope. Result-side
|
|
||||||
> bucketing (`GROUP_ITEM_TYPES`) is still frontend-side — see
|
|
||||||
> [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md)
|
|
||||||
> §Stage 2.
|
|
||||||
|
|
||||||
**Status:** Implemented (boundary revision: query side done, result side pending)
|
|
||||||
**Scope:** Frontend only. No Rust changes required. *(Revised — see banner.)*
|
|
||||||
**Requirements:** UR-049 → DR-063, DR-064, DR-065; UR-050 → DR-066, DR-067
|
|
||||||
(see [requirements.md](../requirements.md)).
|
|
||||||
**UX spec:** [ux-flows.md §6](../ux-flows.md) — §6.1 scope, §6.2 layout,
|
|
||||||
§6.3 group order, §6.4 current deviations.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Two related changes to search:
|
|
||||||
|
|
||||||
1. **Scope** — a search started inside a library searches *that* library.
|
|
||||||
Started from Home, `/library`, or the search tab, it searches everything.
|
|
||||||
The active scope shows as a chip row under the search bar, preselected from
|
|
||||||
context and freely changeable without retyping.
|
|
||||||
2. **Group order** — the order result groups appear in (Songs, Albums, Artists,
|
|
||||||
Movies, TV Shows) becomes a drag-and-drop setting instead of being hardcoded.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
Searching "office" while browsing TV currently returns music albums, because
|
|
||||||
both search entry points call the same unscoped query. The user has already
|
|
||||||
told us what they're looking at; ignoring that makes search feel indiscriminate
|
|
||||||
and pushes the relevant result below unrelated media.
|
|
||||||
|
|
||||||
## Background: what already exists
|
|
||||||
|
|
||||||
Verified in code — **most of the plumbing is already there.** This is
|
|
||||||
substantially a wiring task, not new infrastructure.
|
|
||||||
|
|
||||||
1. **`SearchOptions` already carries the filter.**
|
|
||||||
[bindings.ts](../../src/lib/api/bindings.ts) —
|
|
||||||
`SearchOptions = { limit?, includeItemTypes?, searchTerm? }`.
|
|
||||||
|
|
||||||
2. **Rust already honours `include_item_types` on both paths** — online
|
|
||||||
([online.rs](../../src-tauri/src/repository/online.rs), in the `get_items`
|
|
||||||
options mapping) and offline
|
|
||||||
([offline.rs](../../src-tauri/src/repository/offline.rs), which builds a SQL
|
|
||||||
type filter from it). **Do not add Rust code for filtering.**
|
|
||||||
|
|
||||||
3. **Per-page list search already does this correctly.**
|
|
||||||
[GenericMediaListPage.svelte](../../src/lib/components/library/GenericMediaListPage.svelte)
|
|
||||||
passes `includeItemTypes: [config.itemType]` to `repo.search(...)`. Use it as
|
|
||||||
the reference for the call shape, including the `requestId` handling.
|
|
||||||
|
|
||||||
4. **The gap is exactly one function.**
|
|
||||||
[library.ts](../../src/lib/stores/library.ts) — `search(query)` takes only a
|
|
||||||
query and calls `repo.search(query, { limit: 10000 }, requestId)`, dropping
|
|
||||||
any scope. Both callers
|
|
||||||
([search/+page.svelte](../../src/routes/search/+page.svelte) and
|
|
||||||
[library/+layout.svelte](../../src/routes/library/+layout.svelte)) go through
|
|
||||||
it.
|
|
||||||
|
|
||||||
5. **Group order is hardcoded in markup.**
|
|
||||||
[SearchResults.svelte](../../src/lib/components/search/SearchResults.svelte)
|
|
||||||
categorizes into `music{tracks,albums,artists} / movies / tvShows` and
|
|
||||||
renders three fixed sections in source order.
|
|
||||||
|
|
||||||
6. **Frontend preferences persist via `localStorage`**, per the existing
|
|
||||||
`viewMode` precedent in [library.ts](../../src/lib/stores/library.ts)
|
|
||||||
(`jellytau-view-mode`). Follow that pattern — **do not** add a Rust settings
|
|
||||||
command for this.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### Scope model
|
|
||||||
|
|
||||||
One `SearchScope` type, defined once and shared:
|
|
||||||
|
|
||||||
| Scope | `includeItemTypes` | Chip label |
|
|
||||||
|-------|--------------------|------------|
|
|
||||||
| `all` | *unset* | All |
|
|
||||||
| `music` | `MusicAlbum`, `MusicArtist`, `Audio`, `Playlist` | Music |
|
|
||||||
| `movies` | `Movie` | Movies |
|
|
||||||
| `tv` | `Series`, `Episode` | TV |
|
|
||||||
|
|
||||||
`all` must send **no** `includeItemTypes` key rather than a list of every type —
|
|
||||||
the two are not equivalent for item types not enumerated here (Person, folders).
|
|
||||||
|
|
||||||
### Route → scope resolution (DR-063)
|
|
||||||
|
|
||||||
A pure function, unit-testable without a DOM:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
resolveSearchScope(pathname: string): SearchScope
|
|
||||||
```
|
|
||||||
|
|
||||||
- `/library/music*` → `music`
|
|
||||||
- `/library/movies*` → `movies`
|
|
||||||
- `/library/tv*` → `tv`
|
|
||||||
- `/`, `/library`, `/search`, anything else → `all`
|
|
||||||
|
|
||||||
Note `/library/shows/genres` exists as a route; treat `shows` as `tv`. Check the
|
|
||||||
current route list before finalising — do not assume this table is exhaustive.
|
|
||||||
|
|
||||||
### Scope is a starting point, not a lock (DR-064)
|
|
||||||
|
|
||||||
The resolved scope sets the **initial** chip only. Once the user taps a chip,
|
|
||||||
their choice governs until they leave the search surface. Concretely: derive the
|
|
||||||
initial value from the route, hold it in component state, and do not re-derive
|
|
||||||
it on every navigation — otherwise a user who widens to All snaps back to TV.
|
|
||||||
|
|
||||||
Changing a chip re-runs the current query at the new scope. Changing the query
|
|
||||||
keeps the current scope.
|
|
||||||
|
|
||||||
### Threading scope through the store (DR-065)
|
|
||||||
|
|
||||||
Extend the store's search signature to accept an optional scope and pass
|
|
||||||
`includeItemTypes` down to `repo.search`. Preserve the existing behaviour
|
|
||||||
exactly: the `requestId` bump, the stale-response guard, the `search-event`
|
|
||||||
listener merge, the 10s timeout, and the empty-query clear path. This is an
|
|
||||||
additive parameter — no caller should break.
|
|
||||||
|
|
||||||
### Group order (DR-066, DR-067)
|
|
||||||
|
|
||||||
Persist an ordered array of group ids:
|
|
||||||
|
|
||||||
```
|
|
||||||
["songs", "albums", "artists", "movies", "tvShows"] // shipped default
|
|
||||||
```
|
|
||||||
|
|
||||||
Rendering composes scope and order as **two independent axes**, in this order:
|
|
||||||
|
|
||||||
1. drop groups outside the active scope,
|
|
||||||
2. sort the remainder by the user's saved order,
|
|
||||||
3. omit groups that came back empty.
|
|
||||||
|
|
||||||
Scope never rewrites the saved order — narrowing to Music and back to All must
|
|
||||||
restore the user's full arrangement. See [ux-flows.md §6.3](../ux-flows.md) for
|
|
||||||
the worked example.
|
|
||||||
|
|
||||||
Settings gets a reorderable list. **Dragging alone is not sufficient**: provide
|
|
||||||
keyboard-operable move up/down controls with proper labels, or the setting is
|
|
||||||
unusable with a screen reader and on any pointerless input.
|
|
||||||
|
|
||||||
Unknown or missing ids in the stored array must not crash rendering — treat the
|
|
||||||
stored order as a hint, append any group it doesn't mention, and ignore ids that
|
|
||||||
no longer exist. A user upgrading from a build with fewer groups must not lose
|
|
||||||
the new ones.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- Ranking *within* a group. Order is presentation-only.
|
|
||||||
- Server-side search ranking or the Jellyfin query itself.
|
|
||||||
- Scope chips on the per-page list search in `GenericMediaListPage` — that page
|
|
||||||
is already implicitly scoped by its own `itemType`.
|
|
||||||
- Any Rust change.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [ ] Searching from inside Music returns no movies or TV; from inside TV, no music.
|
|
||||||
- [ ] Searching from Home, `/library`, or the search tab returns all types.
|
|
||||||
- [ ] The chip row renders under the search bar on both the search page and the
|
|
||||||
in-library header search, with the context-derived chip preselected.
|
|
||||||
- [ ] Tapping a chip re-runs the search with the query preserved; editing the
|
|
||||||
query preserves the selected chip.
|
|
||||||
- [ ] Tapping "All" from a context-scoped search widens results without retyping.
|
|
||||||
- [ ] Result groups render in the user's configured order, with out-of-scope and
|
|
||||||
empty groups omitted and relative order preserved.
|
|
||||||
- [ ] Group order is reorderable by drag **and** by keyboard, persists across
|
|
||||||
restarts, and ships with the documented default.
|
|
||||||
- [ ] Offline search respects scope (the offline path already filters — verify,
|
|
||||||
don't reimplement).
|
|
||||||
- [ ] `bun run check` and `bun run test` pass.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Follow the existing frontend test conventions (vitest, `src/lib/**/*.test.ts`).
|
|
||||||
|
|
||||||
- `resolveSearchScope` — pure unit tests over the route table, including the
|
|
||||||
`/library/shows/genres` case and unknown routes falling back to `all`.
|
|
||||||
- Scope → `includeItemTypes` mapping, asserting `all` omits the key entirely.
|
|
||||||
- The compose step: scope filter + user order + empty-group omission, including
|
|
||||||
the "narrow then widen restores order" case and a stored order containing an
|
|
||||||
unknown id.
|
|
||||||
- Store-level: scoped search forwards `includeItemTypes` to the repository, and
|
|
||||||
the existing stale-`requestId` guard still discards superseded responses.
|
|
||||||
|
|
||||||
New requirement-implementing code needs `TRACES:` comments — see
|
|
||||||
[CLAUDE.md](../../CLAUDE.md). Suggested tags: the scope resolver and chip row
|
|
||||||
`UR-049 | DR-063, DR-064`, the store change `UR-049 | DR-065`, the settings list
|
|
||||||
and ordered rendering `UR-050 | DR-066, DR-067`.
|
|
||||||
|
|
||||||
## Notes for the implementer
|
|
||||||
|
|
||||||
- Read [ux-flows.md §6](../ux-flows.md) first — it is the behavioural spec; this
|
|
||||||
document is the implementation plan.
|
|
||||||
- The IPC camelCase rule applies to anything new that crosses the boundary
|
|
||||||
([CLAUDE.md](../../CLAUDE.md)) — though this change should not add commands.
|
|
||||||
- Another session may be active in this repo. Check `git diff` before
|
|
||||||
"repairing" unexpected changes.
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
# Spec: Windows native audio backend
|
|
||||||
|
|
||||||
**Status:** Proposed — not started. Windows still runs on
|
|
||||||
`WebviewAudioBackend`. Blocked on [libmpv2-migration.md](libmpv2-migration.md),
|
|
||||||
whose crate swap has not landed either.
|
|
||||||
**Requirements:** UR-003, UR-027, UR-032, UR-033 → DR-030, DR-035, DR-036;
|
|
||||||
⚠️ the suggested id **IR-030 has since been allocated** to the scheduled catalog
|
|
||||||
crawl — allocate a fresh id (IR-033 or later) on implementation
|
|
||||||
**UX spec:** n/a — Settings › Audio already renders the controls
|
|
||||||
**Supersedes / revises:** acts on the "audio can unify, video cannot" conclusion in [playback-backend-unification.md](playback-backend-unification.md)
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Give Windows a real native audio backend instead of the current webview
|
|
||||||
`<audio>` shim. Windows is the only platform where audio playback has no decoder
|
|
||||||
of its own: `WebviewAudioBackend` hands a URL to a frontend `<audio>` element and
|
|
||||||
relays transport commands. It cannot set volume, cannot apply any audio setting,
|
|
||||||
and reports state only via DOM events.
|
|
||||||
|
|
||||||
Audio needs no rendering surface, so **none of the webview-compositing problems
|
|
||||||
that block unified video apply here.** This is the cleanest available win.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
`WebviewAudioBackend` was a deliberate stopgap ("audio-only playback for
|
|
||||||
platforms without a native audio backend"), and it works — but it has a hard
|
|
||||||
functional gap. From `webview_audio_backend.rs`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
|
|
||||||
// ...stores locally only; there is no ControlCommand action for volume
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
So volume changes never reach the element; the frontend has to observe the player
|
|
||||||
store and apply volume itself. `set_audio_settings` likewise stores values that
|
|
||||||
nothing consumes — EQ, normalization, and gapless are all inert on Windows.
|
|
||||||
|
|
||||||
Meanwhile the backend-unification investigation established that a native *audio*
|
|
||||||
engine is unproblematic on Windows specifically: `tauri-plugin-libmpv` lists
|
|
||||||
Windows as its **fully tested** platform (in contrast to Linux, where embedding
|
|
||||||
is broken — but that is a *video surface* problem, which audio does not have).
|
|
||||||
|
|
||||||
## Layer assignment
|
|
||||||
|
|
||||||
| Logic / responsibility | Layer | Why it belongs there |
|
|
||||||
|------------------------|-------|----------------------|
|
|
||||||
| Decoding and playing the audio stream | Rust | Playback is domain logic; every other platform already decodes in Rust or a native player. The webview shim is the anomaly. |
|
|
||||||
| Applying `AudioSettings` (EQ/normalize/gapless) | Rust | Same `AudioSettings` contract as MPV/ExoPlayer; band layout and presets stay canonical in `settings.rs`. |
|
|
||||||
| Position/state reporting | Rust | Restores the project's core principle — the player is the authoritative source of state. Today Windows inverts this: the DOM element is authoritative and Rust mirrors it. |
|
|
||||||
| Volume | Rust | Currently broken precisely because it is split across the boundary. |
|
|
||||||
| Rendering the player UI | Frontend | Unchanged. |
|
|
||||||
|
|
||||||
The strongest argument for this change is the third row. CLAUDE.md states
|
|
||||||
playback state is one-directional with the player authoritative; on Windows that
|
|
||||||
is currently false, and the `player_report_*` round-trip exists to paper over it.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### Engine choice
|
|
||||||
|
|
||||||
Two viable options; **libmpv is recommended** for consistency with the Linux
|
|
||||||
audio backend.
|
|
||||||
|
|
||||||
| | libmpv | GStreamer |
|
|
||||||
|---|---|---|
|
|
||||||
| Windows status | ✅ `tauri-plugin-libmpv` reports fully tested | ✅ works, but… |
|
|
||||||
| Rust bindings | `libmpv2` 6.0.0, active | `gstreamer-rs` 0.25.x, excellent |
|
|
||||||
| Cross-MSVC from Linux | ⚠️ needs prebuilt DLL + import lib | ❌ `gstreamer-sys` uses pkg-config, fights `cargo-xwin` |
|
|
||||||
| Code reuse | ✅ `MpvBackend` logic is directly reusable | ❌ a second engine to learn |
|
|
||||||
| Crossfade capable | ❌ single-stream chain | ✅ `audiomixer` |
|
|
||||||
|
|
||||||
libmpv wins on reuse: `MpvBackend`'s `set_audio_settings` — the `af` lavfi graph
|
|
||||||
built by `build_af_filter`, `eq_filter_entries`, `normalize_filter_entry` — is
|
|
||||||
platform-independent and would apply unchanged.
|
|
||||||
|
|
||||||
The one reason to prefer GStreamer is crossfade (UR-031), which mpv structurally
|
|
||||||
cannot do. If crossfade becomes a priority, revisit; it would then argue for
|
|
||||||
GStreamer on *both* Linux and Windows, which is a much larger change.
|
|
||||||
|
|
||||||
### Structure
|
|
||||||
|
|
||||||
Rename the cfg gate so `MpvBackend` is no longer Linux-only:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// src-tauri/src/player/mod.rs
|
|
||||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
|
||||||
pub mod mpv_backend;
|
|
||||||
```
|
|
||||||
|
|
||||||
`MpvBackend::new` needs one platform-specific branch: `detect_audio_system()`
|
|
||||||
currently probes `pactl`/`pw-cli`/`/proc/asound/cards` to pick an `ao`. On
|
|
||||||
Windows the equivalent is `wasapi` (mpv's default), so the detection is a
|
|
||||||
`#[cfg]` returning `"wasapi"` — no probing needed.
|
|
||||||
|
|
||||||
Everything else — the event loop, the 250ms position thread, the seek-suppression
|
|
||||||
window, the `af` filter graph — is unchanged.
|
|
||||||
|
|
||||||
`WebviewAudioBackend` stays for other targets (macOS and anything else hitting
|
|
||||||
the `not(any(...))` arm) and as the fallback if libmpv fails to initialize. The
|
|
||||||
existing `emit_backend_init_failed` path already handles that gracefully.
|
|
||||||
|
|
||||||
### Build
|
|
||||||
|
|
||||||
`libmpv2-sys` is well-suited to cross-compilation: no pkg-config, vendored
|
|
||||||
headers, pregenerated bindings (no libclang). It emits `cargo:rustc-link-lib=mpv`
|
|
||||||
unconditionally, so the build must supply a linkable import library for
|
|
||||||
`x86_64-pc-windows-msvc`.
|
|
||||||
|
|
||||||
Keep the `build_libmpv` feature **off** — its Unix path shells out to mpv-build
|
|
||||||
and explicitly rejects cross-compilation.
|
|
||||||
|
|
||||||
🔴 Per CLAUDE.md, the prebuilt libmpv **must be added to the builder image**
|
|
||||||
(`Dockerfile.builder` → rebuild + push via `scripts/build-builder-image.sh`), not
|
|
||||||
installed at CI job time. `libmpv-2.dll` must also be bundled into the NSIS
|
|
||||||
installer via `tauri.conf.json`'s resources.
|
|
||||||
|
|
||||||
### Verified build mechanics
|
|
||||||
|
|
||||||
The cross-compile path was tested hands-on from Linux (July 2026), not inferred:
|
|
||||||
|
|
||||||
- Neither shinchiro nor zhongfly ships an `mpv.def` or MSVC `mpv.lib` — only a
|
|
||||||
MinGW `libmpv.dll.a`. (Several online sources claim otherwise; they are wrong.)
|
|
||||||
- An MSVC-style import lib can be generated locally with LLVM tools only:
|
|
||||||
`llvm-readobj --coff-exports libmpv-2.dll` → synthesize `mpv.def` →
|
|
||||||
`llvm-dlltool -m i386:x86-64 -d mpv.def -l mpv.lib`. `llvm-lib /def:` produces a
|
|
||||||
byte-identical result.
|
|
||||||
- A real `lld-link` link against that import lib **succeeds**, and the resulting
|
|
||||||
import table resolves `mpv_client_api_version` from `libmpv-2.dll`. `lld-link`
|
|
||||||
is the linker `cargo-xwin` uses, so this is the load-bearing step.
|
|
||||||
- Linking directly against the shipped MinGW `libmpv.dll.a` **also** succeeds, so
|
|
||||||
def-generation may be skippable — but that relies on lld's GNU-archive
|
|
||||||
tolerance rather than a documented contract. Keep `llvm-dlltool` as the
|
|
||||||
fallback.
|
|
||||||
- MinGW origin is not an ABI problem: libmpv exports a pure C ABI, and the x86-64
|
|
||||||
Windows calling convention is platform-defined. The upstream note that MSVC
|
|
||||||
cannot *build* mpv is frequently misread as "MSVC cannot *link* libmpv" — that
|
|
||||||
is not what it says.
|
|
||||||
- 🔴 Never free/realloc across the DLL boundary — use `mpv_free`.
|
|
||||||
|
|
||||||
Build wiring is ordinary: `cargo:rustc-link-lib=dylib=mpv` plus
|
|
||||||
`cargo:rustc-link-search`. Nothing about libmpv conflicts with `cargo-xwin`.
|
|
||||||
|
|
||||||
### Size and shipping
|
|
||||||
|
|
||||||
Measured uncompressed: **93 MiB** (zhongfly `mpv-dev-lgpl-x86_64`) vs **112 MiB**
|
|
||||||
(shinchiro, full GPL build); ~26–30 MB compressed in the `.7z`.
|
|
||||||
|
|
||||||
**Ship the zhongfly LGPL build** — smaller, and there is no reason to pull the
|
|
||||||
GPL variant in for an audio-only use.
|
|
||||||
|
|
||||||
Import-table inspection confirms **no companion DLLs are needed**: every
|
|
||||||
dependency is a system DLL (`KERNEL32`, `USER32`, `d2d1`, `DWrite`, `OPENGL32`,
|
|
||||||
`vulkan-1`, UCRT `api-ms-win-*`). One file to bundle.
|
|
||||||
|
|
||||||
93 MiB is still substantial against a Tauri app's usual few MB. Since we use mpv
|
|
||||||
audio-only, investigate whether a pruned build (no video decoders, no libplacebo)
|
|
||||||
is worth producing for the builder image — but treat that as an optimization,
|
|
||||||
not a blocker.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- Windows *video*. Stays in WebView2 + hls.js — it works and has ABR.
|
|
||||||
- Crossfade (UR-031/DR-034) — not implemented anywhere; needs its own spec.
|
|
||||||
- Replacing `WebviewAudioBackend` for macOS.
|
|
||||||
- MPRIS/SMTC media-key integration — worth a follow-up, not this spec.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [ ] Windows build produces a `MpvBackend`-backed player; `backend-init-failed` is emitted (not a crash) if libmpv is unavailable.
|
|
||||||
- [ ] Volume control works from the UI — the current hard gap.
|
|
||||||
- [ ] EQ, normalization, and gapless audibly take effect on Windows.
|
|
||||||
- [ ] Position/state originate in Rust; the `<audio>` element is no longer in the audio path.
|
|
||||||
- [ ] Seek, next/previous, and queue advance work; sleep timer stops playback.
|
|
||||||
- [ ] `libmpv-2.dll` ships in the NSIS installer and the app runs on a clean Windows VM with no mpv installed.
|
|
||||||
- [ ] Builder image carries the Windows libmpv artefacts; **no toolchain install added to any CI step**.
|
|
||||||
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass.
|
|
||||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
|
||||||
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
**Rust**: the existing `mpv_backend_test.rs` and the `build_af_filter` /
|
|
||||||
`normalize_filter_entry` / `eq_filter_entries` unit tests already cover the
|
|
||||||
filter-graph logic and are platform-independent — they should pass unchanged
|
|
||||||
under a Windows `cargo check`/test. Add a test asserting `detect_audio_system()`
|
|
||||||
returns `wasapi` under `cfg(windows)`.
|
|
||||||
|
|
||||||
**Manual, on Windows**: volume, EQ preset change, normalization toggle, gapless
|
|
||||||
between two tracks, seek, queue advance, sleep timer. Then the packaging test —
|
|
||||||
install the NSIS output on a clean VM and confirm it launches and plays.
|
|
||||||
|
|
||||||
Per CLAUDE.md, the volume gap is a *bug fix*: write a failing test for
|
|
||||||
"`set_volume` reaches the backend" before implementing.
|
|
||||||
|
|
||||||
## TRACES
|
|
||||||
|
|
||||||
- Windows `MpvBackend` construction in `create_player_backend` → `// TRACES: UR-003 | IR-030`
|
|
||||||
- `detect_audio_system` Windows branch → `IR-030`
|
|
||||||
- Existing `set_audio_settings` gains Windows coverage → `UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036`
|
|
||||||
- Allocate **IR-030** in `requirements.md` ("libmpv integration for Windows audio playback").
|
|
||||||
|
|
||||||
## Notes for the implementer
|
|
||||||
|
|
||||||
- Do this **after** [libmpv2-migration.md](libmpv2-migration.md) — porting the
|
|
||||||
current dead `libmpv` git pin to a second platform would double the migration
|
|
||||||
work.
|
|
||||||
- `libmpv2` has broken its API in every major release (4.0 removed command
|
|
||||||
helpers, 5.0 removed `mpv_node`, 6.0 changed `RenderContext` ownership). Pin an
|
|
||||||
exact version.
|
|
||||||
- Only the `render`-feature parts of `libmpv2` concern video; audio-only use does
|
|
||||||
not need it, and disabling the default `render` feature may shrink the build.
|
|
||||||
- A parallel Claude session may be active — `git diff` first.
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
# Requirement Traceability CI/CD Pipeline
|
|
||||||
|
|
||||||
This document explains the automated requirement traceability validation system for JellyTau.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The CI/CD pipeline automatically validates that code changes are properly traced to requirements. This ensures:
|
|
||||||
- ✅ Requirements are implemented with clear traceability
|
|
||||||
- ✅ No requirement coverage regressions
|
|
||||||
- ✅ Code changes are linked to specific requirements
|
|
||||||
- ✅ Quality metrics are tracked over time
|
|
||||||
|
|
||||||
## Gitea Actions Workflows
|
|
||||||
|
|
||||||
Traceability validation lives in `.gitea/workflows/traceability-check.yml`:
|
|
||||||
|
|
||||||
- ✅ Automatic trace extraction
|
|
||||||
- ✅ Coverage validation against minimum threshold (88%, ratcheted)
|
|
||||||
- ✅ Modified file checking
|
|
||||||
- ✅ Artifact preservation
|
|
||||||
- ✅ Summary reports
|
|
||||||
|
|
||||||
**Runs on:** Every push and pull request to `master`/`main`/`develop`
|
|
||||||
|
|
||||||
A second workflow, `traceability.yml`, previously duplicated this one as a
|
|
||||||
"GitHub-compatible alternative". It was removed: CI here is Gitea Actions, and
|
|
||||||
its only unique step (PR comments via `actions/github-script`) depended on the
|
|
||||||
GitHub REST client, which Gitea does not provide. To add PR comments, post to
|
|
||||||
Gitea's `/api/v1/repos/{owner}/{repo}/issues/{index}/comments` from
|
|
||||||
`traceability-check.yml` rather than reviving the old file.
|
|
||||||
|
|
||||||
## What Gets Validated
|
|
||||||
|
|
||||||
### 1. Trace Extraction
|
|
||||||
```bash
|
|
||||||
bun run traces:json > traces-report.json
|
|
||||||
```
|
|
||||||
Extracts all TRACES comments from:
|
|
||||||
- TypeScript files (`src/**/*.ts`)
|
|
||||||
- Svelte components (`src/**/*.svelte`)
|
|
||||||
- Rust code (`src-tauri/src/**/*.rs`)
|
|
||||||
- Test files
|
|
||||||
|
|
||||||
### 2. Coverage Thresholds
|
|
||||||
The workflow checks:
|
|
||||||
- **Minimum overall coverage:** 88% (`MIN_THRESHOLD`)
|
|
||||||
|
|
||||||
Denominators are **derived from `docs/requirements.md` at run time** — they are
|
|
||||||
never hardcoded here or in the workflow. Run `bun run traces:coverage` for the
|
|
||||||
current per-type breakdown; any number written into this document is a snapshot
|
|
||||||
that will drift.
|
|
||||||
|
|
||||||
> **Why this matters.** The workflow used to divide by frozen literals
|
|
||||||
> (UR/39, IR/24, DR/48, JA/3, total 114) while `requirements.md` had grown past
|
|
||||||
> 200. It reported **158%** coverage, so the 50% threshold was unreachable and
|
|
||||||
> the job could not fail regardless of how far coverage dropped. See
|
|
||||||
> The fix derives the denominators from `requirements.md` at run time.
|
|
||||||
|
|
||||||
Coverage is the *intersection* of traced and defined IDs: an ID that appears in
|
|
||||||
a `TRACES:` comment but is not defined in `requirements.md` is reported as
|
|
||||||
**orphaned** and does not count toward coverage. UT/IT test identifiers are a
|
|
||||||
separate taxonomy and are excluded entirely.
|
|
||||||
|
|
||||||
The workflow **fails** and blocks merge if coverage drops below the threshold —
|
|
||||||
or if it computes above 100%, which can only mean the gate is miscounting.
|
|
||||||
|
|
||||||
#### Ratchet policy
|
|
||||||
|
|
||||||
`MIN_THRESHOLD` **only ever goes up.** It is deliberately set a few points below
|
|
||||||
the coverage actually achieved (88 against a real ~90%), so a genuine regression
|
|
||||||
trips it. It previously sat at 50 while true coverage was 86%: nearly half the
|
|
||||||
matrix could have rotted before CI objected. It was ratcheted 50 → 82 when that
|
|
||||||
was found, and 82 → 88 once coverage had held above 88% for several releases.
|
|
||||||
|
|
||||||
When coverage rises durably, raise the threshold to just under the new figure.
|
|
||||||
**Never lower it to make a red build pass** — add the missing TRACES comments
|
|
||||||
instead. The same number lives in `MIN_COVERAGE_PERCENT` in
|
|
||||||
`scripts/extract-traces.ts` (so `bun run traces:coverage` gates locally on the
|
|
||||||
same bar); `scripts/extract-traces.test.ts` fails if the two drift apart.
|
|
||||||
|
|
||||||
### 2b. Dangling requirement IDs
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run traces:validate
|
|
||||||
```
|
|
||||||
|
|
||||||
Every ID named by a `TRACES:` comment must be defined as a table row in
|
|
||||||
`docs/requirements.md`. The extractor used to accept any well-formed ID
|
|
||||||
silently, so a typo or a rename that missed a call site passed unnoticed —
|
|
||||||
`DR-189` and `UT-188` were referenced from three source files, defined nowhere,
|
|
||||||
for months.
|
|
||||||
|
|
||||||
This check spans **all six** ID types (UR/IR/DR/JA/UT/IT), unlike the coverage
|
|
||||||
`orphaned` list above, which considers only the four requirement types so that
|
|
||||||
UT/IT noise cannot bury a real typo in the ratio's reporting. The workflow step
|
|
||||||
**fails the build** on any dangling ID and prints each offender with the files
|
|
||||||
that reference it.
|
|
||||||
|
|
||||||
### 3. Modified File Checking
|
|
||||||
On pull requests, the workflow:
|
|
||||||
1. Detects all changed TypeScript/Svelte/Rust files
|
|
||||||
2. Warns if new/modified files lack TRACES comments
|
|
||||||
3. Suggests the TRACES format for missing comments
|
|
||||||
|
|
||||||
## How to Add Traces to New Code
|
|
||||||
|
|
||||||
When you add new code or modify existing code, include TRACES comments:
|
|
||||||
|
|
||||||
### TypeScript/Svelte Example
|
|
||||||
```typescript
|
|
||||||
// TRACES: UR-005, UR-026 | DR-029
|
|
||||||
export function handlePlayback() {
|
|
||||||
// Implementation...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rust Example
|
|
||||||
```rust
|
|
||||||
/// TRACES: UR-005 | DR-001
|
|
||||||
pub fn player_state_changed(state: PlayerState) {
|
|
||||||
// Implementation...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Example
|
|
||||||
```rust
|
|
||||||
// TRACES: UR-005 | DR-001 | UT-026, UT-027
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
// Tests...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## TRACES Format
|
|
||||||
|
|
||||||
```
|
|
||||||
TRACES: [UR-###, ...] | [IR-###, ...] | [DR-###, ...] | [JA-###, ...]
|
|
||||||
```
|
|
||||||
|
|
||||||
- `UR-###` - User Requirements (features users see)
|
|
||||||
- `IR-###` - Integration Requirements (API/platform integration)
|
|
||||||
- `DR-###` - Development Requirements (internal architecture)
|
|
||||||
- `JA-###` - Jellyfin API Requirements (Jellyfin API usage)
|
|
||||||
|
|
||||||
**Examples:**
|
|
||||||
- `// TRACES: UR-005` - Single requirement
|
|
||||||
- `// TRACES: UR-005, UR-026` - Multiple of same type
|
|
||||||
- `// TRACES: UR-005 | DR-029` - Multiple types
|
|
||||||
- `// TRACES: UR-005, UR-026 | DR-001, DR-029 | UT-001` - Complex
|
|
||||||
|
|
||||||
## Workflow Behavior
|
|
||||||
|
|
||||||
### On Push to Main Branch
|
|
||||||
1. ✅ Extracts all traces from code
|
|
||||||
2. ✅ Validates coverage is >= 88%
|
|
||||||
3. ✅ Generates full traceability report
|
|
||||||
4. ✅ Saves report as artifact
|
|
||||||
|
|
||||||
### On Pull Request
|
|
||||||
1. ✅ Extracts all traces
|
|
||||||
2. ✅ Validates coverage >= 88%
|
|
||||||
3. ✅ Checks modified files for TRACES
|
|
||||||
4. ✅ Warns if new code lacks TRACES
|
|
||||||
5. ✅ Suggests proper format
|
|
||||||
6. ✅ Generates report artifact
|
|
||||||
|
|
||||||
### Failure Scenarios
|
|
||||||
The workflow **fails** (blocks merge) if:
|
|
||||||
- Coverage drops below 88%
|
|
||||||
- A `TRACES:` comment names an ID `docs/requirements.md` does not define
|
|
||||||
- JSON extraction fails
|
|
||||||
- Invalid trace format
|
|
||||||
|
|
||||||
The workflow **warns** (but doesn't block) if:
|
|
||||||
- New files lack TRACES comments
|
|
||||||
- Coverage drops (but still above threshold)
|
|
||||||
|
|
||||||
## Viewing Reports
|
|
||||||
|
|
||||||
### In Gitea Actions UI
|
|
||||||
1. Go to **Actions** tab
|
|
||||||
2. Click the **Traceability Validation** workflow run
|
|
||||||
3. Download **traceability-reports** artifact
|
|
||||||
4. View:
|
|
||||||
- `traces-report.json` - Raw trace data
|
|
||||||
- `docs/traceability.md` - Formatted report
|
|
||||||
|
|
||||||
### Locally
|
|
||||||
```bash
|
|
||||||
# Extract current traces
|
|
||||||
bun run traces:json | jq '.byType'
|
|
||||||
|
|
||||||
# Generate full report
|
|
||||||
bun run traces:markdown
|
|
||||||
cat docs/traceability.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## Coverage Goals
|
|
||||||
|
|
||||||
### Current Status
|
|
||||||
|
|
||||||
Run `bun run traces:coverage` — it prints the live figure and exits non-zero
|
|
||||||
below threshold. Numbers are deliberately not pinned here; the previous snapshot
|
|
||||||
in this section (51%, 56/114) was stale by roughly 100 requirements and was what
|
|
||||||
made the broken CI arithmetic look plausible for so long.
|
|
||||||
|
|
||||||
As of August 2026 overall coverage is ~90%.
|
|
||||||
|
|
||||||
### Targets
|
|
||||||
- **Short term** (Sprint): Maintain ≥88% overall (the current ratchet)
|
|
||||||
- **Medium term** (Month): Hold above 90% and ratchet the gate to match
|
|
||||||
- **Long term** (Release): Reach 95% coverage with focus on:
|
|
||||||
- IR requirements (API clients)
|
|
||||||
- JA requirements (Jellyfin API endpoints)
|
|
||||||
- Remaining UR/DR requirements
|
|
||||||
|
|
||||||
## Improving Coverage
|
|
||||||
|
|
||||||
### For Missing User Requirements (UR)
|
|
||||||
1. Review [README.md](../README.md) for unimplemented features
|
|
||||||
2. Add TRACES to code that implements them
|
|
||||||
3. Focus on high-priority features (High/Medium priority)
|
|
||||||
|
|
||||||
### For Missing Integration Requirements (IR)
|
|
||||||
1. Add TRACES to Jellyfin API client methods
|
|
||||||
2. Add TRACES to platform-specific backends (Android/Linux)
|
|
||||||
3. Link to corresponding Jellyfin API endpoints
|
|
||||||
|
|
||||||
### For Missing Development Requirements (DR)
|
|
||||||
1. Add TRACES to UI components in `src/lib/components/`
|
|
||||||
2. Add TRACES to composables in `src/lib/composables/`
|
|
||||||
3. Add TRACES to player backend in `src-tauri/src/player/`
|
|
||||||
|
|
||||||
### For Jellyfin API Requirements (JA)
|
|
||||||
1. Add TRACES to Jellyfin API wrapper methods
|
|
||||||
2. Document which endpoints map to which requirements
|
|
||||||
3. Link to Jellyfin API documentation
|
|
||||||
|
|
||||||
## Example PR Checklist
|
|
||||||
|
|
||||||
When submitting a pull request:
|
|
||||||
|
|
||||||
- [ ] All new code has TRACES comments linking to requirements
|
|
||||||
- [ ] TRACES format is correct: `// TRACES: UR-001 | DR-002`
|
|
||||||
- [ ] Workflow passes (coverage ≥ 88%)
|
|
||||||
- [ ] No coverage regressions
|
|
||||||
- [ ] Artifact traceability report was generated
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### "Coverage below minimum threshold"
|
|
||||||
**Problem:** Workflow fails with coverage < 88%
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Run `bun run traces:json` locally
|
|
||||||
2. Check which requirements are traced
|
|
||||||
3. Add TRACES to untraced code sections
|
|
||||||
4. Re-run extraction to verify
|
|
||||||
|
|
||||||
### "New files without TRACES"
|
|
||||||
**Problem:** Workflow warns about new files lacking TRACES
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Add TRACES comments to all new code
|
|
||||||
2. Format: `// TRACES: UR-001 | DR-002`
|
|
||||||
3. Map code to specific requirements from README.md
|
|
||||||
4. Re-push
|
|
||||||
|
|
||||||
### "Invalid JSON format"
|
|
||||||
**Problem:** Trace extraction produces invalid JSON
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Check for malformed TRACES comments
|
|
||||||
2. Run locally: `bun run traces:json`
|
|
||||||
3. Look for parsing errors
|
|
||||||
4. Fix and retry
|
|
||||||
|
|
||||||
## Integration with Development
|
|
||||||
|
|
||||||
### Before Committing
|
|
||||||
```bash
|
|
||||||
# Check your traces
|
|
||||||
bun run traces:json | jq '.byType'
|
|
||||||
|
|
||||||
# Regenerate report
|
|
||||||
bun run traces:markdown
|
|
||||||
|
|
||||||
# Verify traces syntax
|
|
||||||
grep "TRACES:" src/**/*.ts src/**/*.rs
|
|
||||||
```
|
|
||||||
|
|
||||||
### In Your IDE
|
|
||||||
Add a file watcher to regenerate traces on save:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"fileWatcher.watchPatterns": [
|
|
||||||
"src/**/*.ts",
|
|
||||||
"src/**/*.svelte",
|
|
||||||
"src-tauri/src/**/*.rs"
|
|
||||||
],
|
|
||||||
"fileWatcher.command": "bun run traces:markdown"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Git Hooks
|
|
||||||
Add a pre-push hook to validate traces:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
# .git/hooks/pre-push
|
|
||||||
bun run traces:json > /dev/null
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "❌ Invalid TRACES format"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [Extract Traces Script](../scripts/README.md#extract-tracests)
|
|
||||||
- [Requirements Specification](../README.md#requirements-specification)
|
|
||||||
- [Traceability Matrix](./traceability.md)
|
|
||||||
- [Gitea Actions Documentation](https://docs.gitea.io/en-us/actions/)
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
For issues or questions:
|
|
||||||
1. Check this document
|
|
||||||
2. Review example traces in `src/lib/stores/`
|
|
||||||
3. Check existing TRACES comments for format
|
|
||||||
4. Review workflow logs in Gitea Actions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated:** 2026-02-13
|
|
||||||
-16410
File diff suppressed because it is too large
Load Diff
@@ -1,213 +0,0 @@
|
|||||||
# TRACES Quick Reference Guide
|
|
||||||
|
|
||||||
## What are TRACES?
|
|
||||||
|
|
||||||
TRACES are requirement identifiers embedded in code comments to track which requirements are implemented where.
|
|
||||||
|
|
||||||
Format: `// TRACES: UR-001, UR-002 | DR-003`
|
|
||||||
|
|
||||||
## Quick Examples
|
|
||||||
|
|
||||||
### TypeScript
|
|
||||||
```typescript
|
|
||||||
// TRACES: UR-005, UR-026 | DR-029
|
|
||||||
export function handlePlayback() { }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resume playback from saved position
|
|
||||||
* TRACES: UR-019 | DR-022
|
|
||||||
*/
|
|
||||||
export async function resumePlayback(itemId: string) { }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Svelte
|
|
||||||
```svelte
|
|
||||||
<!-- TRACES: UR-007, UR-008 | DR-007 -->
|
|
||||||
<script>
|
|
||||||
export let items = [];
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rust
|
|
||||||
```rust
|
|
||||||
/// TRACES: UR-005 | DR-001
|
|
||||||
pub enum PlayerState { ... }
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_queue_next() {
|
|
||||||
// TRACES: UR-005 | DR-005 | UT-003
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Requirement Types
|
|
||||||
|
|
||||||
| Type | Meaning | Example |
|
|
||||||
|------|---------|---------|
|
|
||||||
| **UR** | User Requirement | UR-005: Control media playback |
|
|
||||||
| **IR** | Integration Requirement | IR-003: LibMPV integration |
|
|
||||||
| **DR** | Development Requirement | DR-001: Player state machine |
|
|
||||||
| **JA** | Jellyfin API Requirement | JA-007: Get playback info |
|
|
||||||
| **UT** | Unit Test | UT-001: Player state transitions |
|
|
||||||
| **IT** | Integration Test | IT-003: Audio playback via libmpv |
|
|
||||||
|
|
||||||
## Where to Find Requirements
|
|
||||||
|
|
||||||
1. **User Requirements (UR):** [requirements.md](requirements.md#1-user-requirements)
|
|
||||||
2. **Integration Requirements (IR):** [requirements.md](requirements.md#21-integration-requirements)
|
|
||||||
3. **Development Requirements (DR):** [requirements.md](requirements.md#23-development-requirements)
|
|
||||||
4. **Jellyfin API (JA):** [requirements.md](requirements.md#22-jellyfin-api-requirements)
|
|
||||||
|
|
||||||
## How to Add TRACES
|
|
||||||
|
|
||||||
### Step 1: Find the Requirement
|
|
||||||
Look up the requirement in README.md or the traceability matrix.
|
|
||||||
|
|
||||||
Example: `UR-005: Control media playback (pause, play, skip, scrub)`
|
|
||||||
|
|
||||||
### Step 2: Add Comment
|
|
||||||
Add TRACES comment at the top of the function/type/module:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// TRACES: UR-005
|
|
||||||
export async function playMedia(itemId: string) {
|
|
||||||
// Implementation
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Run Extraction
|
|
||||||
Verify the trace is captured:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run traces:json | jq '.requirements | keys | grep "UR-005"'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
### Single Requirement
|
|
||||||
```typescript
|
|
||||||
// TRACES: UR-005
|
|
||||||
function handlePlay() { }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multiple Requirements, Same Type
|
|
||||||
```typescript
|
|
||||||
// TRACES: UR-005, UR-026, UR-019
|
|
||||||
function handlePlaybackState() { }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multiple Types
|
|
||||||
```typescript
|
|
||||||
// TRACES: UR-005, UR-026 | DR-029
|
|
||||||
function autoplayNextEpisode() { }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Coverage
|
|
||||||
```typescript
|
|
||||||
// TRACES: UR-005 | UT-001
|
|
||||||
#[test]
|
|
||||||
fn test_player_state_transition() { }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Modules/Files
|
|
||||||
```typescript
|
|
||||||
/**
|
|
||||||
* Player event handling
|
|
||||||
* TRACES: UR-005, UR-019, UR-023 | DR-001, DR-028
|
|
||||||
*/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
### Check Your Changes
|
|
||||||
```bash
|
|
||||||
# View current coverage
|
|
||||||
bun run traces:json | jq '.byType'
|
|
||||||
|
|
||||||
# Generate full report
|
|
||||||
bun run traces:markdown
|
|
||||||
|
|
||||||
# Check specific requirement
|
|
||||||
bun run traces:json | jq '.requirements."UR-005"'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Before Committing
|
|
||||||
1. Ensure all new code has TRACES
|
|
||||||
2. Format is correct: `// TRACES: ...`
|
|
||||||
3. Requirements exist in `docs/requirements.md` — `bun run traces:validate`
|
|
||||||
4. No typos in requirement IDs (same command catches them)
|
|
||||||
|
|
||||||
## CI/CD Validation
|
|
||||||
|
|
||||||
The workflow automatically checks:
|
|
||||||
- ✅ Coverage stays >= 88% (a ratchet — raise it, never lower it)
|
|
||||||
- ✅ Every traced ID is defined in `docs/requirements.md`
|
|
||||||
- ✅ New files have TRACES
|
|
||||||
- ✅ JSON format is valid
|
|
||||||
- ✅ Reports are generated
|
|
||||||
|
|
||||||
See [traceability-ci.md](traceability-ci.md) for details.
|
|
||||||
|
|
||||||
## Tips & Tricks
|
|
||||||
|
|
||||||
### Find Related Code
|
|
||||||
```bash
|
|
||||||
# Find all code tracing to UR-005
|
|
||||||
bun run traces:json | jq '.requirements."UR-005"'
|
|
||||||
|
|
||||||
# List all tests
|
|
||||||
bun run traces:json | jq '.requirements | keys | map(select(startswith("UT")))'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update Your Editor
|
|
||||||
|
|
||||||
**VS Code:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"editor.wordBasedSuggestions": false,
|
|
||||||
"editor.suggest.custom": [
|
|
||||||
{
|
|
||||||
"name": "TRACES Format",
|
|
||||||
"insertText": "// TRACES: $1",
|
|
||||||
"insertTextRules": "InsertAsSnippet"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Find Untraced Code
|
|
||||||
```bash
|
|
||||||
# Files modified without TRACES
|
|
||||||
git diff --name-only | xargs grep -L "TRACES:" | head -10
|
|
||||||
```
|
|
||||||
|
|
||||||
## FAQ
|
|
||||||
|
|
||||||
**Q: Do I need TRACES on every function?**
|
|
||||||
A: Only for code that implements requirements. Internal helpers don't need TRACES.
|
|
||||||
|
|
||||||
**Q: Can I use TRACES on multiple related functions?**
|
|
||||||
A: Yes! Add at the file/module level or on individual functions.
|
|
||||||
|
|
||||||
**Q: What if code doesn't relate to any requirement?**
|
|
||||||
A: Leave it untraced. TRACES are for requirement-driven development.
|
|
||||||
|
|
||||||
**Q: How often should I regenerate reports?**
|
|
||||||
A: Automatically on push (CI/CD). Manually after changes: `bun run traces:markdown`
|
|
||||||
|
|
||||||
**Q: Can I trace to requirements that aren't implemented yet?**
|
|
||||||
A: Yes! TRACES show your implementation plan.
|
|
||||||
|
|
||||||
## See Also
|
|
||||||
|
|
||||||
- [Full Traceability Matrix](traceability.md)
|
|
||||||
- [CI/CD Pipeline Guide](traceability-ci.md)
|
|
||||||
- [Requirements Specification](requirements.md)
|
|
||||||
- [Extraction Script](../scripts/README.md#extract-tracests)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Quick Start:**
|
|
||||||
1. Add `// TRACES: UR-XXX` to new code
|
|
||||||
2. Run `bun run traces:markdown`
|
|
||||||
3. Check `docs/traceability.md`
|
|
||||||
4. Submit PR - workflow validates automatically!
|
|
||||||
-1582
File diff suppressed because it is too large
Load Diff
@@ -1,199 +0,0 @@
|
|||||||
// ESLint flat config for the JellyTau frontend (Svelte 5 + TypeScript strict).
|
|
||||||
//
|
|
||||||
// TRACES: | DR-205
|
|
||||||
//
|
|
||||||
// Scope: `src/` (the presentation layer), `scripts/` (build tooling), and the
|
|
||||||
// root config files. The Rust backend is linted by clippy, not by this config.
|
|
||||||
//
|
|
||||||
// Formatting is NOT ESLint's job here — `eslint-config-prettier` is applied last
|
|
||||||
// and switches off every stylistic rule that would fight `prettier`. Run
|
|
||||||
// `bun run format` / `bun run format:check` for layout.
|
|
||||||
import js from "@eslint/js";
|
|
||||||
import ts from "typescript-eslint";
|
|
||||||
import svelte from "eslint-plugin-svelte";
|
|
||||||
import globals from "globals";
|
|
||||||
import prettier from "eslint-config-prettier";
|
|
||||||
import svelteConfig from "./svelte.config.js";
|
|
||||||
|
|
||||||
export default ts.config(
|
|
||||||
{
|
|
||||||
// Kept in one place so `npx eslint .` and editor integrations agree.
|
|
||||||
ignores: [
|
|
||||||
"node_modules/",
|
|
||||||
".svelte-kit/",
|
|
||||||
// Scratch worktrees (git-ignored) hold full checkouts of this repo,
|
|
||||||
// including their own generated .svelte-kit trees. Without this, `eslint .`
|
|
||||||
// lints every in-flight branch and reports its generated code as ours.
|
|
||||||
".claude/",
|
|
||||||
"build/",
|
|
||||||
"dist/",
|
|
||||||
"coverage/",
|
|
||||||
"package/",
|
|
||||||
"src-tauri/",
|
|
||||||
// Generated by tauri-specta on every Rust build — never hand-edited, and
|
|
||||||
// its shape is dictated by the Rust command definitions.
|
|
||||||
"src/lib/api/bindings.ts",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
js.configs.recommended,
|
|
||||||
...ts.configs.recommended,
|
|
||||||
...svelte.configs.recommended,
|
|
||||||
prettier,
|
|
||||||
...svelte.configs.prettier,
|
|
||||||
|
|
||||||
{
|
|
||||||
languageOptions: {
|
|
||||||
globals: {
|
|
||||||
...globals.browser,
|
|
||||||
...globals.es2021,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
// The logger-facade migration this rule was waiting on is done: the ~468
|
|
||||||
// `console.*` calls that used to live in `src/` are gone, replaced by
|
|
||||||
// `createLogger(...)` from src/lib/utils/logger.ts (DR-204), which is now
|
|
||||||
// the single sink. Nothing is allowed through — not even warn/error —
|
|
||||||
// because the facade's own `warn`/`error` levels are always emitted, so a
|
|
||||||
// raw call has no capability the facade lacks. It only loses the scope tag
|
|
||||||
// and the runtime level control.
|
|
||||||
//
|
|
||||||
// The sink itself is exempted below, as are tests (a test that asserts on
|
|
||||||
// logging has to be able to talk about `console`).
|
|
||||||
"no-console": "error",
|
|
||||||
|
|
||||||
// Unused values are a real signal, but `_`-prefixed args are the
|
|
||||||
// established way to say "this parameter exists for the signature".
|
|
||||||
//
|
|
||||||
// ⚠️ warn, not error: the tree carries ~94 genuinely dead bindings (stale
|
|
||||||
// imports, `$state` left over from refactors, unused `catch (e)`). Every
|
|
||||||
// one is a real finding, but fixing them here would mean ~50 unrelated
|
|
||||||
// files in this tooling commit. Clear the backlog, then promote to
|
|
||||||
// "error".
|
|
||||||
"@typescript-eslint/no-unused-vars": [
|
|
||||||
"warn",
|
|
||||||
{
|
|
||||||
argsIgnorePattern: "^_",
|
|
||||||
varsIgnorePattern: "^_",
|
|
||||||
caughtErrorsIgnorePattern: "^_",
|
|
||||||
destructuredArrayIgnorePattern: "^_",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
// Warn-only rules: each flags something real, but the existing tree has
|
|
||||||
// more instances than can be fixed without swamping unrelated diffs.
|
|
||||||
// Drive these to zero and promote them to "error" — do not delete them.
|
|
||||||
//
|
|
||||||
// `any` at the Tauri IPC boundary, mostly in code predating the
|
|
||||||
// tauri-specta bindings (~25 sites outside tests).
|
|
||||||
"@typescript-eslint/no-explicit-any": "warn",
|
|
||||||
// Empty catch/if bodies that swallow an error.
|
|
||||||
"no-empty": ["warn", { allowEmptyCatch: true }],
|
|
||||||
|
|
||||||
// Prefer `import type` so type-only imports are erased cleanly by the
|
|
||||||
// bundler instead of pulling a module in at run time.
|
|
||||||
"@typescript-eslint/consistent-type-imports": "off",
|
|
||||||
|
|
||||||
// Not applicable to this app (~130 hits, all no-ops). SvelteKit's
|
|
||||||
// `resolve()` exists so hrefs keep working under a non-empty
|
|
||||||
// `kit.paths.base`; JellyTau is an adapter-static SPA served from the
|
|
||||||
// Tauri webview root and svelte.config.js sets no `base`. Re-enable this
|
|
||||||
// the day a base path is introduced — the rule is otherwise correct.
|
|
||||||
// (Declared here, not in the *.svelte block: `goto()` is also called from
|
|
||||||
// plain .ts modules such as src/lib/utils/navigation.ts.)
|
|
||||||
"svelte/no-navigation-without-resolve": "off",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
// Svelte components: the parser needs the project's svelte.config.js so it
|
|
||||||
// resolves preprocessors and Svelte 5 runes the same way the build does.
|
|
||||||
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
parser: ts.parser,
|
|
||||||
svelteConfig,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
// Warn-only — real findings, but each fix is a behavioural refactor that
|
|
||||||
// does not belong in a tooling commit:
|
|
||||||
// require-each-key keyed {#each} changes DOM reuse semantics
|
|
||||||
// prefer-svelte-reactivity Set/Map -> SvelteSet/SvelteMap changes
|
|
||||||
// reactivity, not just syntax
|
|
||||||
// prefer-writable-derived $state + $effect -> writable $derived
|
|
||||||
// no-at-html-tags {@html} sites need an XSS review each
|
|
||||||
"svelte/require-each-key": "warn",
|
|
||||||
"svelte/prefer-svelte-reactivity": "warn",
|
|
||||||
"svelte/prefer-writable-derived": "warn",
|
|
||||||
"svelte/no-at-html-tags": "warn",
|
|
||||||
|
|
||||||
// Warn-only: this rule cannot see the Svelte *compiler's* warning set, so
|
|
||||||
// it reports `<!-- svelte-ignore a11y_… -->` as unused when the compiler
|
|
||||||
// may still be emitting the warning it suppresses. Verify against a real
|
|
||||||
// `bun run check` before deleting any of them.
|
|
||||||
"svelte/no-unused-svelte-ignore": "warn",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
// The logging facade is the one place allowed to touch `console` — it *is*
|
|
||||||
// the sink every other module reaches it through (see the `no-console`
|
|
||||||
// comment above). `createLogger`'s `console[method](...)` dispatch is a
|
|
||||||
// computed member access, which the rule flags like any other.
|
|
||||||
files: ["src/lib/utils/logger.ts"],
|
|
||||||
rules: {
|
|
||||||
"no-console": "off",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
// Node-side tooling: build/test scripts and root config files run under
|
|
||||||
// Bun/Node, not in the webview.
|
|
||||||
files: [
|
|
||||||
"scripts/**/*.{ts,js}",
|
|
||||||
"*.config.{ts,js}",
|
|
||||||
"*.config.*.{ts,js}",
|
|
||||||
"svelte.config.js",
|
|
||||||
"eslint.config.js",
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
globals: {
|
|
||||||
...globals.node,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
// These are command-line tools (extract-traces, release-notes, ...) whose
|
|
||||||
// stdout IS the product — `bun run traces:markdown > docs/traceability.md`
|
|
||||||
// depends on it. The logging facade is a webview concern; a CLI printing
|
|
||||||
// its result is not a stray debug statement.
|
|
||||||
"no-console": "off",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
// Test files: vitest globals are enabled in vitest.config.ts.
|
|
||||||
files: ["**/*.{test,spec}.{ts,js}", "src/test/**/*.{ts,js}"],
|
|
||||||
languageOptions: {
|
|
||||||
globals: {
|
|
||||||
...globals.node,
|
|
||||||
...globals.vitest,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
// Tests are allowed to talk about `console` — several spy on it to assert
|
|
||||||
// what the logging facade emits, and scripts/ tooling tests capture output.
|
|
||||||
"no-console": "off",
|
|
||||||
// Test doubles legitimately use `any` for partial mocks.
|
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
|
||||||
// `vi.mock` factories are hoisted above the import graph, so a lazy
|
|
||||||
// `require()` inside one is the documented escape hatch.
|
|
||||||
"@typescript-eslint/no-require-imports": "off",
|
|
||||||
// Several tests deliberately replay a production assignment sequence
|
|
||||||
// (`currentStreamUrl = newStreamUrl; hasSeeked = false;`) to document the
|
|
||||||
// `$effect` they stand in for. The "useless" write is the subject under
|
|
||||||
// test, not dead code.
|
|
||||||
"no-useless-assignment": "off",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"version": "0.13.3",
|
||||||
|
"notes": "\n### 🐛 Fixes\n\n- **Coming back to the app no longer replaces the page with \"Failed to load\n item\".** After a few minutes in the background, resuming the app on a series\n page could swap the whole page for that error, and it stayed until you\n navigated away. The page refreshes itself on resume; a refresh that fails now\n leaves what you were looking at on screen, and the error clears as soon as a\n load succeeds. When a page genuinely cannot open, it now says why instead of\n the generic message. (DR-297)",
|
||||||
|
"pub_date": "2026-09-24T13:05:54Z",
|
||||||
|
"platforms": {
|
||||||
|
"linux-x86_64": {
|
||||||
|
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSczV4N1FMRVFQaXFhcjl4RnhxV3JmT2R6WWVXMU1lT3k2ZDVIdWt5NkJEN1lpMit2VldTUGl2Y0VEMEp2Tkt3MDZCb0dQb0FMc2JLNEZQVm52NVJ4eGlNcEZWNkR6U3dBPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzkwMjUzMjI3CWZpbGU6SmVsbHlUYXVfMC4xMy4zX2FtZDY0LkFwcEltYWdlCkdYbzN5NGYxSGs5b0xYL09ZN0NEWjlFd0orSTVqR3l4ZkJsQjdqYzc2N2RSRmc3TjJ4Mkl3MytUdWIvRjQ4UnRXU2FVQUxMbDd1M2U4OENrY2kvaEJ3PT0K",
|
||||||
|
"url": "https://gitea.tourolle.paris/dtourolle/jellytau/releases/download/v0.13.3/JellyTau_0.13.3_amd64.AppImage"
|
||||||
|
},
|
||||||
|
"windows-x86_64": {
|
||||||
|
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSczV4N1FMRVFQaWp2OU11cHNlc04xbit3OGtnUDN2dnJMYlR5dTJXMHVyZkwxVDJ3bTlvYkEyM29qdWNDYU1VNDZ4NUpFV2M4Ym9lOUlUbGhuZ2hkeDNSWDV4andSbWc4PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzkwMjUzODkxCWZpbGU6SmVsbHlUYXVfMC4xMy4zX3g2NC1zZXR1cC5leGUKVEhtWU1HNjRWbGpuTzM5K1R0RDl3VWJOYlptbVpiNTQ0OWtJUVZVZ1dRWStYY0k3U05RcUFJYTlQcEc3aitpclczcy8vTzAzcFhFRmF0QlBOTitsQXc9PQo=",
|
||||||
|
"url": "https://gitea.tourolle.paris/dtourolle/jellytau/releases/download/v0.13.3/JellyTau_0.13.3_x64-setup.exe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "jellytau",
|
|
||||||
"version": "0.13.0",
|
|
||||||
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
|
|
||||||
"author": "Duncan Tourolle <duncan@tourolle.paris>",
|
|
||||||
"license": "MIT",
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://gitea.tourolle.paris/dtourolle/jellytau"
|
|
||||||
},
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"packageManager": "bun@1.3.5",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite dev",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
||||||
"test": "vitest run",
|
|
||||||
"test:watch": "vitest",
|
|
||||||
"test:ui": "vitest --ui",
|
|
||||||
"test:coverage": "vitest run --coverage",
|
|
||||||
"test:all": "./scripts/test-all.sh",
|
|
||||||
"test:rust": "./scripts/test-rust.sh",
|
|
||||||
"lint": "eslint .",
|
|
||||||
"lint:fix": "eslint . --fix",
|
|
||||||
"format": "prettier --write .",
|
|
||||||
"format:check": "prettier --check .",
|
|
||||||
"check:boundary": "bash scripts/check-frontend-boundary.sh",
|
|
||||||
"check:links": "bash scripts/check-doc-links.sh",
|
|
||||||
"check:tooling": "bash scripts/check-tooling.sh",
|
|
||||||
"hooks:install": "./scripts/install-hooks.sh",
|
|
||||||
"android:build": "./scripts/build-android.sh",
|
|
||||||
"android:build:release": "./scripts/build-android.sh release",
|
|
||||||
"android:build:device": "./scripts/build-android.sh --device",
|
|
||||||
"android:build:release:device": "./scripts/build-android.sh release --device",
|
|
||||||
"android:build:clean": "rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target && bun install && bun run build",
|
|
||||||
"android:deploy": "./scripts/deploy-android.sh",
|
|
||||||
"android:dev": "./scripts/build-and-deploy.sh",
|
|
||||||
"android:check": "./scripts/check-android.sh",
|
|
||||||
"android:logs": "./scripts/logcat.sh",
|
|
||||||
"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",
|
|
||||||
"traces:json": "bun run scripts/extract-traces.ts --format json",
|
|
||||||
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md",
|
|
||||||
"traces:coverage": "bun run scripts/extract-traces.ts --format coverage",
|
|
||||||
"traces:validate": "bun run scripts/extract-traces.ts --format validate",
|
|
||||||
"release:notes": "bun run scripts/release-notes.ts",
|
|
||||||
"test:player": "./scripts/test-player-conformance.sh",
|
|
||||||
"test:player:android": "./scripts/test-player-conformance.sh android"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@tauri-apps/api": "^2.11.1",
|
|
||||||
"@tauri-apps/plugin-log": "2.9.0",
|
|
||||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
|
||||||
"@tauri-apps/plugin-os": "^2.3.2",
|
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
|
||||||
"@tauri-apps/plugin-updater": "2.10.1",
|
|
||||||
"hls.js": "^1.6.15",
|
|
||||||
"svelte-dnd-action": "^0.9.69"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/js": "^10.0.1",
|
|
||||||
"@sveltejs/adapter-static": "^3.0.6",
|
|
||||||
"@sveltejs/kit": "^2.9.0",
|
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
|
||||||
"@tauri-apps/cli": "^2.11.4",
|
|
||||||
"@testing-library/svelte": "^5.3.1",
|
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
|
||||||
"@vitest/ui": "^4.0.16",
|
|
||||||
"eslint": "^10.8.1",
|
|
||||||
"eslint-config-prettier": "^10.1.8",
|
|
||||||
"eslint-plugin-svelte": "^3.23.0",
|
|
||||||
"globals": "^17.11.0",
|
|
||||||
"happy-dom": "^20.0.11",
|
|
||||||
"jsdom": "^27.4.0",
|
|
||||||
"prettier": "^3.9.6",
|
|
||||||
"prettier-plugin-svelte": "^4.1.1",
|
|
||||||
"svelte": "^5.47.1",
|
|
||||||
"svelte-check": "^4.0.0",
|
|
||||||
"tailwindcss": "^4.1.18",
|
|
||||||
"typescript": "~5.6.2",
|
|
||||||
"typescript-eslint": "^8.67.0",
|
|
||||||
"vite": "^6.0.3",
|
|
||||||
"vitest": "^4.1.10"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
# 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.11.5
|
|
||||||
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.
|
|
||||||
#
|
|
||||||
# 🔴 `tauri/custom-protocol` is not optional. `tauri build` passes it for you;
|
|
||||||
# a bare `cargo build` does not, and without it Tauri loads the frontend from
|
|
||||||
# `devUrl` rather than the assets embedded from `frontendDist`. The result
|
|
||||||
# builds and installs cleanly and then cannot load its own UI. check() guards
|
|
||||||
# this.
|
|
||||||
(cd src-tauri && cargo build --release --locked --features tauri/custom-protocol)
|
|
||||||
}
|
|
||||||
|
|
||||||
check() {
|
|
||||||
cd "$_srcdir"
|
|
||||||
|
|
||||||
# A Tauri binary built without `custom-protocol` does not embed the frontend;
|
|
||||||
# it serves it from `devUrl` (http://localhost:1420) instead. It compiles,
|
|
||||||
# links and installs perfectly, then launches into "Could not connect to
|
|
||||||
# localhost: Connection refused" — which is what this package did for its
|
|
||||||
# entire existence, because `tauri build` adds that feature for you and a bare
|
|
||||||
# `cargo build` does not.
|
|
||||||
#
|
|
||||||
# Test for the *assets*, not for the dev URL: `devUrl` is part of the config
|
|
||||||
# blob that generate_context!() embeds either way, so its presence proves
|
|
||||||
# nothing. A content-hashed filename from the vite build can only be in the
|
|
||||||
# binary if the bundle was embedded — the with-feature binary is ~400 KB
|
|
||||||
# larger for exactly this reason.
|
|
||||||
local _binary="src-tauri/target/release/jellytau"
|
|
||||||
local _asset
|
|
||||||
_asset="$(basename "$(ls -1 build/_app/immutable/entry/*.js | head -n1)")"
|
|
||||||
|
|
||||||
if [ -z "$_asset" ]; then
|
|
||||||
echo "==> ERROR: no frontend build found — 'bun run build' did not produce build/_app." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! grep -qa "$_asset" "$_binary"; then
|
|
||||||
echo "==> ERROR: the frontend bundle is not embedded in the binary." >&2
|
|
||||||
echo " Build with --features tauri/custom-protocol, or the packaged app" >&2
|
|
||||||
echo " will start up unable to load its own UI." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
||||||
# MIT is not in /usr/share/licenses/common, so Arch packaging requires the
|
|
||||||
# licence text to ship with the package.
|
|
||||||
install -Dm644 "LICENSE" \
|
|
||||||
"$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
|
||||||
|
|
||||||
# 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"
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
# Development Scripts
|
|
||||||
|
|
||||||
Collection of utility scripts for building, testing, and deploying JellyTau.
|
|
||||||
|
|
||||||
## Testing Scripts
|
|
||||||
|
|
||||||
### `test-all.sh`
|
|
||||||
Run all tests (frontend + Rust backend).
|
|
||||||
```bash
|
|
||||||
./scripts/test-all.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### `test-frontend.sh`
|
|
||||||
Run frontend tests only.
|
|
||||||
```bash
|
|
||||||
./scripts/test-frontend.sh # Single pass (same as `bun run test`)
|
|
||||||
./scripts/test-frontend.sh --watch # Watch mode
|
|
||||||
./scripts/test-frontend.sh --ui # Open UI
|
|
||||||
```
|
|
||||||
|
|
||||||
`bun run test` is `vitest run` — one pass, exit code, done. It used to be bare
|
|
||||||
`vitest`, which parked in watch mode; CLAUDE.md's "Before Committing" list tells
|
|
||||||
people to run it, so it had to terminate. The interactive modes moved to their
|
|
||||||
own entry points:
|
|
||||||
|
|
||||||
| Command | Runs |
|
|
||||||
|---------|------|
|
|
||||||
| `bun run test` | `vitest run` — single pass |
|
|
||||||
| `bun run test:watch` | `vitest` — watch mode |
|
|
||||||
| `bun run test:ui` | `vitest --ui` |
|
|
||||||
| `bun run test:coverage` | `vitest run --coverage` |
|
|
||||||
|
|
||||||
`test-frontend.sh` forwards any extra arguments to vitest and switches to the
|
|
||||||
long-running form automatically when it sees `--watch`, `-w`, or `--ui`.
|
|
||||||
|
|
||||||
### `test-rust.sh`
|
|
||||||
Run Rust tests only.
|
|
||||||
```bash
|
|
||||||
./scripts/test-rust.sh # Run all tests
|
|
||||||
./scripts/test-rust.sh -- --nocapture # Show println! output
|
|
||||||
```
|
|
||||||
|
|
||||||
## Android Scripts
|
|
||||||
|
|
||||||
### `build-android.sh`
|
|
||||||
Build the Android APK.
|
|
||||||
```bash
|
|
||||||
./scripts/build-android.sh # Debug build
|
|
||||||
./scripts/build-android.sh release # Release build
|
|
||||||
```
|
|
||||||
|
|
||||||
### `deploy-android.sh`
|
|
||||||
Install APK on connected Android device.
|
|
||||||
```bash
|
|
||||||
./scripts/deploy-android.sh # Deploy debug APK
|
|
||||||
./scripts/deploy-android.sh release # Deploy release APK
|
|
||||||
```
|
|
||||||
|
|
||||||
### `build-and-deploy.sh`
|
|
||||||
Build and deploy in one command.
|
|
||||||
```bash
|
|
||||||
./scripts/build-and-deploy.sh # Build + deploy debug
|
|
||||||
./scripts/build-and-deploy.sh release # Build + deploy release
|
|
||||||
```
|
|
||||||
|
|
||||||
### `check-android.sh`
|
|
||||||
Check Android development environment setup.
|
|
||||||
```bash
|
|
||||||
./scripts/check-android.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### `logcat.sh`
|
|
||||||
View Android logcat filtered for the app.
|
|
||||||
```bash
|
|
||||||
./scripts/logcat.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Traceability & Documentation
|
|
||||||
|
|
||||||
### `extract-traces.ts`
|
|
||||||
Extract requirement IDs (TRACES) from source code and generate a traceability matrix mapping requirements to implementation locations.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run traces # Generate markdown report
|
|
||||||
bun run traces:json # Generate JSON report
|
|
||||||
bun run traces:markdown # Save to docs/traceability.md
|
|
||||||
bun run traces:coverage # Coverage gate — exits non-zero below the ratchet
|
|
||||||
bun run traces:validate # Dangling-ID gate — every traced ID must be defined
|
|
||||||
```
|
|
||||||
|
|
||||||
The script scans all TypeScript, Svelte, and Rust files (plus `scripts/`)
|
|
||||||
looking for `TRACES:` comments and generates a comprehensive mapping of:
|
|
||||||
- Which code files implement which requirements
|
|
||||||
- Line numbers and code context
|
|
||||||
- Coverage summary by requirement type (UR, IR, DR, JA)
|
|
||||||
|
|
||||||
**`bun run traces:coverage` is the supported way to check requirement coverage
|
|
||||||
locally** — it runs the same computation CI does. Coverage denominators are
|
|
||||||
derived from `docs/requirements.md` at run time; they are never hardcoded. An ID
|
|
||||||
that appears in a `TRACES:` comment but is not defined in `requirements.md` is
|
|
||||||
reported as *orphaned* and does not count toward coverage (see DR-093).
|
|
||||||
|
|
||||||
**`bun run traces:validate` is the dangling-ID gate.** It fails if any traced ID
|
|
||||||
— including `UT`/`IT`, which coverage deliberately ignores — is not defined as a
|
|
||||||
table row in `requirements.md`, printing each offender with the files that
|
|
||||||
reference it. Without it the extractor accepted any well-formed ID silently, so
|
|
||||||
typos and renames that missed a call site went unreported for months.
|
|
||||||
|
|
||||||
> **Removed:** `check-req-coverage.sh`, `check-test-coverage.sh`, and
|
|
||||||
> `find-req-implementations.sh` were deleted in July 2026. They read an
|
|
||||||
> undocumented `@req:` tag convention parallel to `TRACES:`, grepped `src-tauri/`
|
|
||||||
> unscoped (hanging on ~40 GB of `target/` artifacts), and in one case reported
|
|
||||||
> "all requirements implemented" from an empty result set. `extract-traces.ts` is
|
|
||||||
> the single source of truth for requirement coverage. See
|
|
||||||
> it reported `Total Requirements: 1` and then "All requirements have
|
|
||||||
> implementations!". Nothing referenced it. Use `bun run traces:coverage`.
|
|
||||||
|
|
||||||
Example TRACES comment in code:
|
|
||||||
```typescript
|
|
||||||
// TRACES: UR-005, UR-026 | DR-029
|
|
||||||
function handlePlayback() { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
See [docs/traceability.md](../docs/traceability.md) for the latest generated mapping.
|
|
||||||
|
|
||||||
### CI/CD Validation
|
|
||||||
|
|
||||||
The traceability system is integrated with Gitea Actions CI/CD:
|
|
||||||
- Automatically validates TRACES on every push and pull request
|
|
||||||
- Enforces a minimum coverage threshold (a ratchet: raise it, never lower it)
|
|
||||||
- Fails on dangling IDs — traced but undefined in `requirements.md`
|
|
||||||
- Warns if new code lacks TRACES comments
|
|
||||||
- Generates traceability reports automatically
|
|
||||||
|
|
||||||
For details, see:
|
|
||||||
- [Traceability CI Guide](../docs/traceability-ci.md) - Full CI/CD documentation
|
|
||||||
- [TRACES Quick Reference](../docs/traces-quick-ref.md) - Quick guide for adding TRACES
|
|
||||||
|
|
||||||
## Linting & Formatting
|
|
||||||
|
|
||||||
There is no script wrapper for these — they are plain package.json entries:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run lint # eslint .
|
|
||||||
bun run lint:fix # eslint . --fix
|
|
||||||
bun run format # prettier --write .
|
|
||||||
bun run format:check # prettier --check .
|
|
||||||
```
|
|
||||||
|
|
||||||
Config lives in `eslint.config.js` (flat config: typescript-eslint +
|
|
||||||
eslint-plugin-svelte, tuned for Svelte 5 and TS `strict`), `.prettierrc`, and
|
|
||||||
`.prettierignore`. `src/lib/api/bindings.ts` is excluded from both — it is
|
|
||||||
generated by tauri-specta on every Rust build.
|
|
||||||
|
|
||||||
`bun run lint` is currently **error-clean but not warning-clean**: several rules
|
|
||||||
are deliberately set to `warn` because the existing tree has more hits than a
|
|
||||||
tooling change should touch (unused bindings, `any` at the IPC boundary, unkeyed
|
|
||||||
`{#each}`). Each one is annotated in `eslint.config.js` with why, and the
|
|
||||||
intended end state is `error`. Drive them down; do not delete them.
|
|
||||||
|
|
||||||
`no-console` is switched **off** for now — see the note in `eslint.config.js`.
|
|
||||||
|
|
||||||
## Git Hooks
|
|
||||||
|
|
||||||
### `install-hooks.sh`
|
|
||||||
Point git at the repo's tracked hooks directory (`core.hooksPath`).
|
|
||||||
```bash
|
|
||||||
bun run hooks:install # or: ./scripts/install-hooks.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### `hooks/pre-commit`
|
|
||||||
Runs the fast half of CLAUDE.md's "Before Committing" list so it is enforced
|
|
||||||
rather than remembered:
|
|
||||||
|
|
||||||
- `bun run check` (svelte-check)
|
|
||||||
- `bun run test` (vitest, single pass)
|
|
||||||
- `scripts/check-frontend-boundary.sh`
|
|
||||||
- `cargo fmt --all -- --check`, **only when staged files touch `src-tauri/`**
|
|
||||||
|
|
||||||
`cargo clippy` and `cargo test` are deliberately *not* in the hook — minutes per
|
|
||||||
commit is how you teach people to reach for `--no-verify`. They run in CI, and
|
|
||||||
locally via `bun run test:all`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git commit --no-verify # skip the hook for one commit
|
|
||||||
git config --unset core.hooksPath # uninstall
|
|
||||||
```
|
|
||||||
|
|
||||||
The hook skips itself during a merge, rebase, or cherry-pick, and when nothing
|
|
||||||
is staged.
|
|
||||||
|
|
||||||
## Utility Scripts
|
|
||||||
|
|
||||||
### `clean.sh`
|
|
||||||
Clean all build artifacts.
|
|
||||||
```bash
|
|
||||||
./scripts/clean.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## NPM Script Aliases
|
|
||||||
|
|
||||||
You can also run these via npm/bun:
|
|
||||||
```bash
|
|
||||||
bun run test # Frontend tests (single pass)
|
|
||||||
bun run test:all # All tests
|
|
||||||
bun run test:rust # Rust tests
|
|
||||||
bun run lint # ESLint
|
|
||||||
bun run format:check # Prettier (check only)
|
|
||||||
bun run hooks:install # Install the git hooks
|
|
||||||
bun run android:build # Build Android APK
|
|
||||||
bun run android:deploy # Deploy to device
|
|
||||||
bun run android:dev # Build + deploy debug
|
|
||||||
bun run android:check # Check environment
|
|
||||||
bun run clean # Clean artifacts
|
|
||||||
```
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "🚀 JellyTau Android Development Helper"
|
|
||||||
echo "======================================"
|
|
||||||
|
|
||||||
# Setup environment
|
|
||||||
echo "Setting up environment..."
|
|
||||||
source "$HOME/.cargo/env.fish" 2>/dev/null || source "$HOME/.cargo/env" || true
|
|
||||||
export ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}"
|
|
||||||
export NDK_HOME="$ANDROID_HOME/ndk/$(ls $ANDROID_HOME/ndk 2>/dev/null | head -1)"
|
|
||||||
|
|
||||||
# Check prerequisites
|
|
||||||
echo -e "\n✓ Checking prerequisites..."
|
|
||||||
|
|
||||||
if ! command -v rustc &> /dev/null; then
|
|
||||||
echo "❌ Rust not found. Please install from https://rustup.rs"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! command -v adb &> /dev/null; then
|
|
||||||
echo "❌ ADB not found. Please install Android SDK"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -d "$ANDROID_HOME" ]; then
|
|
||||||
echo "⚠️ ANDROID_HOME not found at $ANDROID_HOME"
|
|
||||||
echo " Please install Android SDK or update the path"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check for connected devices
|
|
||||||
echo -e "\n📱 Connected devices:"
|
|
||||||
adb devices
|
|
||||||
|
|
||||||
# Menu
|
|
||||||
echo -e "\n📋 What would you like to do?"
|
|
||||||
echo "1) Run in development mode (hot reload)"
|
|
||||||
echo "2) Build debug APK"
|
|
||||||
echo "3) Build release APK"
|
|
||||||
echo "4) Install debug APK to device"
|
|
||||||
echo "5) Check environment"
|
|
||||||
read -p "Select option (1-5): " choice
|
|
||||||
|
|
||||||
case $choice in
|
|
||||||
1)
|
|
||||||
echo -e "\n🔨 Starting development mode..."
|
|
||||||
bun run tauri android dev
|
|
||||||
;;
|
|
||||||
2)
|
|
||||||
echo -e "\n🔨 Building debug APK..."
|
|
||||||
bun run tauri android build --debug
|
|
||||||
echo -e "\n✅ Debug APK built at:"
|
|
||||||
echo " src-tauri/gen/android/app/build/outputs/apk/debug/app-debug.apk"
|
|
||||||
;;
|
|
||||||
3)
|
|
||||||
echo -e "\n🔨 Building release APK..."
|
|
||||||
bun run tauri android build
|
|
||||||
echo -e "\n✅ Release APK built at:"
|
|
||||||
echo " src-tauri/gen/android/app/build/outputs/apk/release/"
|
|
||||||
;;
|
|
||||||
4)
|
|
||||||
APK="src-tauri/gen/android/app/build/outputs/apk/debug/app-debug.apk"
|
|
||||||
if [ -f "$APK" ]; then
|
|
||||||
echo -e "\n📲 Installing to device..."
|
|
||||||
adb install -r "$APK"
|
|
||||||
echo "✅ Installed!"
|
|
||||||
else
|
|
||||||
echo "❌ APK not found. Build it first (option 2)"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
5)
|
|
||||||
echo -e "\n🔍 Environment Check:"
|
|
||||||
echo " Rust: $(rustc --version 2>/dev/null || echo 'Not found')"
|
|
||||||
echo " Cargo: $(cargo --version 2>/dev/null || echo 'Not found')"
|
|
||||||
echo " Bun: $(bun --version 2>/dev/null || echo 'Not found')"
|
|
||||||
echo " ADB: $(adb --version 2>/dev/null | head -1 || echo 'Not found')"
|
|
||||||
echo " ANDROID_HOME: $ANDROID_HOME"
|
|
||||||
echo " NDK_HOME: $NDK_HOME"
|
|
||||||
echo ""
|
|
||||||
echo " Rust Android targets:"
|
|
||||||
rustup target list 2>/dev/null | grep android | grep installed || echo " None installed"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Invalid option"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Build and deploy Android APK in one command
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "🚀 Build and Deploy Android APK"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Pass all args (build type and/or --clean) through to the build script.
|
|
||||||
./scripts/build-android.sh "$@"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Deploy APK — forward the build type and the side-by-side flag (which decides
|
|
||||||
# which package to launch), ignoring build-only flags like --clean and --device.
|
|
||||||
DEPLOY_ARGS=("debug")
|
|
||||||
for arg in "$@"; do
|
|
||||||
case "$arg" in
|
|
||||||
debug|release) DEPLOY_ARGS[0]="$arg" ;;
|
|
||||||
--debug|--side-by-side) DEPLOY_ARGS+=("--side-by-side") ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
./scripts/deploy-android.sh "${DEPLOY_ARGS[@]}"
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Build Android APK
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Source Rust environment
|
|
||||||
source "$HOME/.cargo/env.fish" 2>/dev/null || source "$HOME/.cargo/env" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Set Android environment variables.
|
|
||||||
#
|
|
||||||
# Defaults, not overrides. A developer's SDK is at ~/Android/Sdk, but CI runs in
|
|
||||||
# the builder image where it lives at /opt/android-sdk and the job sets
|
|
||||||
# ANDROID_HOME accordingly — hardcoding the home-directory path here silently
|
|
||||||
# discarded that and the build died with "Android SDK not found" a minute in.
|
|
||||||
# `test-player-conformance.sh` already had this right; this script did not.
|
|
||||||
export ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}"
|
|
||||||
export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
|
|
||||||
|
|
||||||
if [ ! -d "$ANDROID_HOME/ndk" ]; then
|
|
||||||
echo "❌ No NDK directory at $ANDROID_HOME/ndk" >&2
|
|
||||||
echo " Set ANDROID_HOME to your SDK location, or install the NDK." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Respect an NDK the caller has already picked (CI pins an exact revision via
|
|
||||||
# ANDROID_NDK_HOME); otherwise take whatever is installed.
|
|
||||||
export NDK_HOME="${NDK_HOME:-${ANDROID_NDK_HOME:-$ANDROID_HOME/ndk/$(ls "$ANDROID_HOME/ndk" | head -1)}}"
|
|
||||||
export ANDROID_NDK_HOME="$NDK_HOME"
|
|
||||||
|
|
||||||
echo "🤖 Building Android APK..."
|
|
||||||
echo "Android SDK: $ANDROID_HOME"
|
|
||||||
echo "NDK: $NDK_HOME"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Parse args: build type (debug/release) and optional --clean flag.
|
|
||||||
# By default the build is INCREMENTAL — Cargo and Vite reuse their caches.
|
|
||||||
# Pass --clean (or CLEAN=1) to wipe all caches for a from-scratch build.
|
|
||||||
#
|
|
||||||
# ABI selection: by default Tauri builds all four ABIs (arm64/arm/x86/x86_64),
|
|
||||||
# which is what a distributable universal APK needs — but for an on-device test
|
|
||||||
# it means three wasted Rust compiles. Pass --device (or ABI=aarch64) to build
|
|
||||||
# only the connected device's architecture; --abi <t> targets one explicitly.
|
|
||||||
#
|
|
||||||
# Side-by-side: the `debug` build type always installs as
|
|
||||||
# com.dtourolle.jellytau.debug ("JellyTau Debug"), so it never collides with a
|
|
||||||
# real install. `release --debug` puts a *release* build — R8-minified, exactly
|
|
||||||
# what ships — into that same slot, signed with the local debug keystore. That
|
|
||||||
# is how you validate minification (R8 stripping JNI-loaded classes has broken
|
|
||||||
# release APKs here before) without the real signing key and without
|
|
||||||
# uninstalling the app you actually use.
|
|
||||||
BUILD_TYPE="debug"
|
|
||||||
CLEAN="${CLEAN:-0}"
|
|
||||||
ABI="${ABI:-}"
|
|
||||||
SIDE_BY_SIDE="${SIDE_BY_SIDE:-0}"
|
|
||||||
next_is_abi=0
|
|
||||||
for arg in "$@"; do
|
|
||||||
if [ "$next_is_abi" = "1" ]; then
|
|
||||||
ABI="$arg"
|
|
||||||
next_is_abi=0
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
case "$arg" in
|
|
||||||
--clean) CLEAN=1 ;;
|
|
||||||
--abi) next_is_abi=1 ;;
|
|
||||||
--device) ABI="device" ;;
|
|
||||||
--debug|--side-by-side) SIDE_BY_SIDE=1 ;;
|
|
||||||
debug|release) BUILD_TYPE="$arg" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# The debug build type is side-by-side unconditionally; the flag only means
|
|
||||||
# something for a release build.
|
|
||||||
if [ "$BUILD_TYPE" = "debug" ]; then
|
|
||||||
SIDE_BY_SIDE=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Resolve --device to the attached device's Rust target triple.
|
|
||||||
if [ "$ABI" = "device" ]; then
|
|
||||||
device_abi="$(adb shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r\n')"
|
|
||||||
case "$device_abi" in
|
|
||||||
arm64-v8a) ABI="aarch64" ;;
|
|
||||||
armeabi-v7a) ABI="armv7" ;;
|
|
||||||
x86_64) ABI="x86_64" ;;
|
|
||||||
x86) ABI="i686" ;;
|
|
||||||
*)
|
|
||||||
echo "⚠️ Could not detect device ABI (got '${device_abi:-none}') — building all targets."
|
|
||||||
ABI=""
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
[ -n "$ABI" ] && echo "🎯 Device ABI $device_abi → building only '$ABI'"
|
|
||||||
fi
|
|
||||||
|
|
||||||
TARGET_ARGS=()
|
|
||||||
if [ -n "$ABI" ]; then
|
|
||||||
TARGET_ARGS=(--target "$ABI")
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Step 0: Optionally clear build caches for a fully fresh build.
|
|
||||||
if [ "$CLEAN" = "1" ]; then
|
|
||||||
echo "🧹 Clearing build caches (clean build)..."
|
|
||||||
rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target 2>/dev/null || true
|
|
||||||
# `bun install`, NOT `npm install`. This is a bun project (see packageManager
|
|
||||||
# in package.json) and bun.lock is the lockfile that is committed; npm
|
|
||||||
# ignores it, re-resolves the tree from package.json alone, and writes a
|
|
||||||
# package-lock.json that .gitignore then hides.
|
|
||||||
#
|
|
||||||
# That is not cosmetic. The Tauri CLI refuses to build when a plugin's Rust
|
|
||||||
# crate and npm package differ by minor version, so the JS side is pinned
|
|
||||||
# exactly to match Cargo.lock; a re-resolve is precisely how those halves
|
|
||||||
# drift apart again. A clean build must not be able to change what gets
|
|
||||||
# installed.
|
|
||||||
bun install > /dev/null 2>&1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Step 1: Sync Android source files
|
|
||||||
echo "🔄 Syncing Android sources..."
|
|
||||||
./scripts/sync-android-sources.sh
|
|
||||||
|
|
||||||
# Step 2: Build the frontend first to avoid dev server issues
|
|
||||||
echo "🎨 Building frontend..."
|
|
||||||
bun run build
|
|
||||||
|
|
||||||
# Step 2: Build Android APK
|
|
||||||
# `--apk` is a boolean flag, NOT `--apk true`.
|
|
||||||
#
|
|
||||||
# tauri-cli took a value here until 2.10; from 2.11 it is a plain flag and the
|
|
||||||
# stray `true` is parsed as a positional argument, failing with
|
|
||||||
# "error: unexpected argument 'true' found" before the build starts. Found by
|
|
||||||
# deploying to a device after the Tauri 2.9.5 -> 2.11.5 upgrade.
|
|
||||||
if [ "$BUILD_TYPE" = "release" ] && [ "$SIDE_BY_SIDE" = "1" ]; then
|
|
||||||
# A release build in the debug slot: R8 still runs, but the applicationId is
|
|
||||||
# suffixed and the debug keystore signs it (read by build.gradle.kts from
|
|
||||||
# JT_SIDE_BY_SIDE), so the real key is not needed and it replaces any other
|
|
||||||
# .debug install cleanly. Deliberately does NOT write keystore.properties.
|
|
||||||
echo "📦 Building side-by-side release APK (com.dtourolle.jellytau.debug)..."
|
|
||||||
JT_SIDE_BY_SIDE=1 bun run tauri android build --apk "${TARGET_ARGS[@]}"
|
|
||||||
elif [ "$BUILD_TYPE" = "release" ]; then
|
|
||||||
# Configure release signing from .env (single source of truth). Must run
|
|
||||||
# after sync-android-sources.sh, since gen/android is (re)generated there.
|
|
||||||
./scripts/write-keystore-properties.sh
|
|
||||||
echo "📦 Building release APK..."
|
|
||||||
bun run tauri android build --apk "${TARGET_ARGS[@]}"
|
|
||||||
else
|
|
||||||
echo "📦 Building debug APK..."
|
|
||||||
bun run tauri android build --apk --debug "${TARGET_ARGS[@]}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# The applicationId the APK actually carries — not the one build.gradle.kts asks
|
|
||||||
# for. `tauri android build` rewrites the debug `buildTypes` block in the
|
|
||||||
# generated gradle file to inject its keepDebugSymbols entries, and that rewrite
|
|
||||||
# used to drop `applicationIdSuffix` with it, silently producing a debug APK
|
|
||||||
# under the release applicationId. Installing that over a real release build
|
|
||||||
# fails with INSTALL_FAILED_UPDATE_INCOMPATIBLE, whose only obvious remedy is
|
|
||||||
# uninstalling the release app and losing its data — so this fails the build
|
|
||||||
# instead. The suffix now lives outside the rewritten block (see
|
|
||||||
# src-tauri/android/app/build.gradle.kts); this checks that it survived.
|
|
||||||
assert_application_id() {
|
|
||||||
local variant="$1" expected="$2"
|
|
||||||
local metadata="src-tauri/gen/android/app/build/outputs/apk/universal/$variant/output-metadata.json"
|
|
||||||
|
|
||||||
[ -f "$metadata" ] || return 0
|
|
||||||
|
|
||||||
local actual
|
|
||||||
actual=$(sed -n 's/.*"applicationId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$metadata" | head -1)
|
|
||||||
|
|
||||||
if [ -n "$actual" ] && [ "$actual" != "$expected" ]; then
|
|
||||||
echo ""
|
|
||||||
echo "❌ APK applicationId is '$actual', expected '$expected'."
|
|
||||||
echo " A build meant for the side-by-side slot came out under the"
|
|
||||||
echo " release applicationId; installing it would collide with a real"
|
|
||||||
echo " install. Check that the applicationIdSuffix at the bottom of"
|
|
||||||
echo " src-tauri/android/app/build.gradle.kts survived into"
|
|
||||||
echo " src-tauri/gen/android/app/build.gradle.kts."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ "$BUILD_TYPE" = "debug" ]; then
|
|
||||||
assert_application_id debug "com.dtourolle.jellytau.debug"
|
|
||||||
elif [ "$SIDE_BY_SIDE" = "1" ]; then
|
|
||||||
assert_application_id release "com.dtourolle.jellytau.debug"
|
|
||||||
else
|
|
||||||
assert_application_id release "com.dtourolle.jellytau"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "✅ APK build complete!"
|
|
||||||
echo "📱 APK location: src-tauri/gen/android/app/build/outputs/apk/"
|
|
||||||
|
|
||||||
# Containerised builds run as root against a bind-mounted tree; hand the
|
|
||||||
# artifacts back to the host user. No-op when not root. See DR-213.
|
|
||||||
"$(dirname "$0")/restore-ownership.sh"
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Build and push the JellyTau builder Docker image to your registry
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
REGISTRY_HOST="${REGISTRY_HOST:-gitea.tourolle.paris}"
|
|
||||||
REGISTRY_USER="${REGISTRY_USER:-dtourolle}"
|
|
||||||
IMAGE_NAME="jellytau-builder"
|
|
||||||
IMAGE_TAG="${1:-latest}"
|
|
||||||
FULL_IMAGE_NAME="${REGISTRY_HOST}/${REGISTRY_USER}/${IMAGE_NAME}:${IMAGE_TAG}"
|
|
||||||
|
|
||||||
echo "🐳 Building JellyTau Builder Image"
|
|
||||||
echo "=================================="
|
|
||||||
echo "Registry: $REGISTRY_HOST"
|
|
||||||
echo "User: $REGISTRY_USER"
|
|
||||||
echo "Image: $FULL_IMAGE_NAME"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Step 1: Build locally
|
|
||||||
echo "🔨 Building Docker image locally..."
|
|
||||||
docker build -f Dockerfile.builder -t ${IMAGE_NAME}:${IMAGE_TAG} .
|
|
||||||
|
|
||||||
# Step 2: Tag for registry
|
|
||||||
echo "🏷️ Tagging for registry..."
|
|
||||||
docker tag ${IMAGE_NAME}:${IMAGE_TAG} ${FULL_IMAGE_NAME}
|
|
||||||
|
|
||||||
# Step 3: Login to registry (if not already logged in)
|
|
||||||
#
|
|
||||||
# `docker info | grep Username` only ever reports a Docker Hub session, so for a
|
|
||||||
# private registry it never matched — meaning this branch fired on every push and
|
|
||||||
# dropped into an interactive `docker login`, which hangs any non-interactive run
|
|
||||||
# (a scripted release, or CI). Check the credential store for this specific
|
|
||||||
# registry instead, and refuse rather than prompt when there is no TTY to
|
|
||||||
# prompt on.
|
|
||||||
echo "🔐 Checking registry authentication..."
|
|
||||||
DOCKER_CFG="${DOCKER_CONFIG:-$HOME/.docker}/config.json"
|
|
||||||
if ! grep -q "\"${REGISTRY_HOST}\"" "$DOCKER_CFG" 2>/dev/null; then
|
|
||||||
if [ -t 0 ]; then
|
|
||||||
echo "Not authenticated to ${REGISTRY_HOST}. Logging in..."
|
|
||||||
docker login "${REGISTRY_HOST}"
|
|
||||||
else
|
|
||||||
echo "❌ Not authenticated to ${REGISTRY_HOST}, and stdin is not a TTY."
|
|
||||||
echo " Run this first: docker login ${REGISTRY_HOST}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo " Using stored credentials for ${REGISTRY_HOST}."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Step 4: Push to registry
|
|
||||||
#
|
|
||||||
# Two tags, on purpose:
|
|
||||||
#
|
|
||||||
# <date> what the workflows pin (e.g. :2026.08). CI must name an immutable
|
|
||||||
# tag -- while every job said :latest, rebuilding the image silently
|
|
||||||
# changed what every build, including a rebuild of an old release
|
|
||||||
# tag, compiled against. That is the opposite of reproducible.
|
|
||||||
# latest convenience for local `docker compose` runs and for anyone pulling
|
|
||||||
# the image by hand.
|
|
||||||
#
|
|
||||||
# Date tags rather than per-commit SHA tags: the Gitea runner shares a 74 GB
|
|
||||||
# disk with two other projects, and SHA-tagged images accumulated there until it
|
|
||||||
# filled. Keep at most a couple of dated tags live and prune the rest
|
|
||||||
# (`docker image prune -a` on the runner).
|
|
||||||
#
|
|
||||||
# To bump: build+push a new dated tag, then update the `image:` lines in
|
|
||||||
# .gitea/workflows/*.yml in the same commit as whatever needed the new tool.
|
|
||||||
echo "📤 Pushing image to registry..."
|
|
||||||
docker push ${FULL_IMAGE_NAME}
|
|
||||||
|
|
||||||
if [ "$IMAGE_TAG" != "latest" ]; then
|
|
||||||
echo "🏷️ Also tagging as :latest for local use..."
|
|
||||||
LATEST_IMAGE_NAME="${REGISTRY_HOST}/${REGISTRY_USER}/${IMAGE_NAME}:latest"
|
|
||||||
docker tag ${IMAGE_NAME}:${IMAGE_TAG} ${LATEST_IMAGE_NAME}
|
|
||||||
docker push ${LATEST_IMAGE_NAME}
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "✅ Successfully built and pushed: ${FULL_IMAGE_NAME}"
|
|
||||||
echo ""
|
|
||||||
echo "Workflows must pin the dated tag, not :latest --"
|
|
||||||
echo " container:"
|
|
||||||
echo " image: ${FULL_IMAGE_NAME}"
|
|
||||||
echo ""
|
|
||||||
echo "Currently pinned in .gitea/workflows/:"
|
|
||||||
grep -ho "jellytau-builder:[A-Za-z0-9._-]*" "$(git rev-parse --show-toplevel)"/.gitea/workflows/*.yml 2>/dev/null | sort -u | sed "s/^/ /"
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
#!/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).
|
|
||||||
# TRACES: | DR-221
|
|
||||||
#
|
|
||||||
# 🔴 NO_STRIP=true is required for the AppImage bundle.
|
|
||||||
#
|
|
||||||
# linuxdeploy (which Tauri downloads and runs to build the AppImage) carries its
|
|
||||||
# own `strip`, and that copy is too old to parse the `.relr.dyn` section modern
|
|
||||||
# toolchains emit for RELR relocations. It fails on essentially every bundled
|
|
||||||
# library:
|
|
||||||
#
|
|
||||||
# strip: libzstd.so.1: unknown type [0x13] section `.relr.dyn'
|
|
||||||
# failed to bundle project `failed to run linuxdeploy-x86_64.AppImage`
|
|
||||||
#
|
|
||||||
# Ubuntu 23.10+ links with -z pack-relative-relocs by default, so the CI builder
|
|
||||||
# image hits this exactly as a modern Arch host does. Skipping the strip step is
|
|
||||||
# linuxdeploy's own documented escape hatch; the cost is an unstripped, larger
|
|
||||||
# AppImage (~153 MB for a build that bundles libmpv and its ffmpeg stack).
|
|
||||||
#
|
|
||||||
# Remove this only after confirming a linuxdeploy release that understands RELR.
|
|
||||||
NO_STRIP=true 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
|
|
||||||
|
|
||||||
# Containerised builds run as root against a bind-mounted tree; hand the
|
|
||||||
# artifacts back to the host user. No-op when not root. See DR-213.
|
|
||||||
"$(dirname "$0")/restore-ownership.sh"
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
#!/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/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.
|
|
||||||
# TRACES: | DR-221
|
|
||||||
#
|
|
||||||
# 🔴 Clear the bundle output before building.
|
|
||||||
#
|
|
||||||
# The bundle directory is not versioned and is never cleaned by cargo, and the
|
|
||||||
# CI runner reuses src-tauri/target between builds. The copy step below globs
|
|
||||||
# `bundle/**/*-setup.exe`, so every stale installer left there was picked up and
|
|
||||||
# attached to the release: v0.8.2 shipped sixteen Windows installers, thirteen
|
|
||||||
# of them from earlier versions, and v0.5.0 offered users a download list going
|
|
||||||
# back to 0.1.0. Every release from v0.1.0 to v0.8.2 did this. It stopped only
|
|
||||||
# because an unrelated change wiped the runner's target dir, so it is dormant
|
|
||||||
# rather than fixed.
|
|
||||||
#
|
|
||||||
# Filtering the copy by version would hide it; removing the directory means a
|
|
||||||
# stale file cannot exist to be copied. scripts/check-release-artifacts.sh is
|
|
||||||
# the backstop if some other path reintroduces one.
|
|
||||||
BUNDLE_DIR="src-tauri/target/$TARGET/release/bundle"
|
|
||||||
if [[ -d "$BUNDLE_DIR" ]]; then
|
|
||||||
echo "🧹 Clearing previous bundle output at $BUNDLE_DIR"
|
|
||||||
rm -rf "$BUNDLE_DIR"
|
|
||||||
fi
|
|
||||||
|
|
||||||
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.
|
|
||||||
#
|
|
||||||
# The .sig files come along too: when TAURI_SIGNING_PRIVATE_KEY is set the
|
|
||||||
# bundler writes `<installer>.sig` beside each installer, and that signature is
|
|
||||||
# what the updater verifies before installing anything. Leaving it behind
|
|
||||||
# produces a release whose manifest references a signature that was never
|
|
||||||
# published, which fails only on the user's machine.
|
|
||||||
find "$BIN_DIR/bundle" -type f \( -name '*-setup.exe' -o -name '*.msi' -o -name '*.sig' \) \
|
|
||||||
-exec cp -v {} "$OUTPUT_DIR/" \; 2>/dev/null || true
|
|
||||||
echo ""
|
|
||||||
echo "📦 Copied Windows artifacts to $OUTPUT_DIR"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Containerised builds run as root against a bind-mounted tree; hand the
|
|
||||||
# artifacts back to the host user. No-op when not root. See DR-213.
|
|
||||||
"$(dirname "$0")/restore-ownership.sh"
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Check Android development environment
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "🔍 Checking Android development environment..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check ADB
|
|
||||||
if command -v adb &> /dev/null; then
|
|
||||||
echo "✅ ADB installed: $(adb version | head -1)"
|
|
||||||
else
|
|
||||||
echo "❌ ADB not found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check Android SDK
|
|
||||||
if [ -d "$HOME/Android/Sdk" ]; then
|
|
||||||
echo "✅ Android SDK found at: $HOME/Android/Sdk"
|
|
||||||
else
|
|
||||||
echo "❌ Android SDK not found at: $HOME/Android/Sdk"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check NDK
|
|
||||||
if [ -d "$HOME/Android/Sdk/ndk" ]; then
|
|
||||||
NDK_VERSION=$(ls "$HOME/Android/Sdk/ndk" | head -1)
|
|
||||||
echo "✅ NDK found: $NDK_VERSION"
|
|
||||||
else
|
|
||||||
echo "❌ NDK not found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check Rust
|
|
||||||
if command -v rustc &> /dev/null; then
|
|
||||||
echo "✅ Rust installed: $(rustc --version)"
|
|
||||||
else
|
|
||||||
echo "❌ Rust not found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check Cargo
|
|
||||||
if command -v cargo &> /dev/null; then
|
|
||||||
echo "✅ Cargo installed: $(cargo --version)"
|
|
||||||
else
|
|
||||||
echo "❌ Cargo not found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check for connected devices
|
|
||||||
echo ""
|
|
||||||
echo "📱 Connected Android devices:"
|
|
||||||
if adb devices | grep -q "device$"; then
|
|
||||||
adb devices | grep "device$"
|
|
||||||
else
|
|
||||||
echo "⚠️ No devices connected"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "🔍 Environment check complete!"
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Documentation link integrity: every relative markdown link must point at a
|
|
||||||
# file that exists.
|
|
||||||
#
|
|
||||||
# Implements DR-208 (see docs/requirements.md).
|
|
||||||
#
|
|
||||||
# Why this exists: docs/traceability.md is generated into docs/ while its file
|
|
||||||
# links were emitted repo-root-relative, so all ~2,800 of them resolved to
|
|
||||||
# docs/src-tauri/… and 404'd — in the Gitea repo browser and on the published
|
|
||||||
# mdBook site alike. Nobody clicks 2,800 links, so it went unnoticed for months.
|
|
||||||
# Several hand-written docs had the same defect at smaller scale: links to files
|
|
||||||
# that had been deleted, and links written as if the doc lived at the repo root.
|
|
||||||
# A link that does not resolve is a documentation defect of the same kind as a
|
|
||||||
# compile error, and a grep is enough to catch the whole class.
|
|
||||||
#
|
|
||||||
# What it checks: for every tracked `.md` file, every inline markdown link
|
|
||||||
# `[text](target)` whose target is a *path* — the target is resolved relative to
|
|
||||||
# the directory of the file containing it, and must exist on disk.
|
|
||||||
#
|
|
||||||
# ⚠️ It validates PATHS, NOT ANCHORS. A green run does not mean the links land
|
|
||||||
# where the text claims.
|
|
||||||
#
|
|
||||||
# 🔴 What it deliberately CANNOT see (do not read a green run as proof):
|
|
||||||
# - **Anchor fragments.** `foo.md#some-heading` is checked only as `foo.md`.
|
|
||||||
# Resolving the fragment needs a markdown renderer's heading-slug rules
|
|
||||||
# (which differ between Gitea, GitHub and mdBook), so a link to a heading
|
|
||||||
# that was renamed still passes here. That is a deliberate scope cut, not an
|
|
||||||
# oversight.
|
|
||||||
# - **External URLs.** http(s):// and mailto: are skipped. Checking them means
|
|
||||||
# network I/O in a gate, which makes the gate flaky and slow; link rot in an
|
|
||||||
# external URL is also not something a commit can break.
|
|
||||||
# - **Reference-style links** (`[text][ref]` with a separate `[ref]: target`
|
|
||||||
# definition) and bare autolinks. This project writes inline links; add the
|
|
||||||
# pattern here if that changes.
|
|
||||||
# - **Links inside fenced code blocks**, which are intentionally skipped —
|
|
||||||
# a template being *shown* to the reader (e.g. the release-notes template in
|
|
||||||
# docs/release-checklist.md) is sample text, not a live link, and its targets
|
|
||||||
# are resolved wherever it is eventually pasted, not from the docs tree.
|
|
||||||
# - **A link that resolves to the wrong existing file.** Existence is not
|
|
||||||
# correctness.
|
|
||||||
#
|
|
||||||
# Usage: bash scripts/check-doc-links.sh
|
|
||||||
# Exits non-zero, listing file:line and the unresolved target, on any failure.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
|
|
||||||
# Generated, vendored or build-output trees. Their markdown is not authored here
|
|
||||||
# and their link targets are not ours to fix.
|
|
||||||
#
|
|
||||||
# Only consulted when this is NOT a git checkout — inside one, the tracked-file
|
|
||||||
# list does this job and does not need maintaining. Kept for the tarball case.
|
|
||||||
EXCLUDES=(
|
|
||||||
"./node_modules/*"
|
|
||||||
"./.svelte-kit/*"
|
|
||||||
"./build/*"
|
|
||||||
"./dist/*"
|
|
||||||
"./src-tauri/gen/*"
|
|
||||||
"./src-tauri/target/*"
|
|
||||||
"./.git/*"
|
|
||||||
# Agent/dev scratch worktrees (.claude/worktrees is itself git-ignored). These
|
|
||||||
# are full checkouts of the repo, so without this the checker walks every
|
|
||||||
# in-flight branch and reports its links as if they were ours.
|
|
||||||
"./.claude/*"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Targets that do not exist in the repo *by design* because the publish-docs job
|
|
||||||
# writes them into docs/ at build time (see .gitea/workflows/publish-docs.yml).
|
|
||||||
# Keep this list to genuinely generated pages — anything else here is a broken
|
|
||||||
# link being hidden.
|
|
||||||
GENERATED_TARGETS=(
|
|
||||||
"./docs/README.md" # the site's landing page, written by publish-docs
|
|
||||||
"./docs/api-redirect.md" # the rustdoc redirect stub, likewise
|
|
||||||
)
|
|
||||||
|
|
||||||
is_generated() {
|
|
||||||
local candidate="$1"
|
|
||||||
for generated in "${GENERATED_TARGETS[@]}"; do
|
|
||||||
[[ "$candidate" == "$generated" ]] && return 0
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "🔎 Checking relative markdown links resolve to files on disk…"
|
|
||||||
|
|
||||||
# Ask git which markdown files are ours, rather than walking the filesystem.
|
|
||||||
#
|
|
||||||
# This started as a find(1) with a hand-maintained prune list, and that list was
|
|
||||||
# wrong three times in a row: it walked the scratch worktrees under .claude/,
|
|
||||||
# then makepkg's vendored cargo registry under packaging/arch/src/ — each time
|
|
||||||
# reporting a dependency's broken README as if it were ours. Every one of those
|
|
||||||
# directories is already git-ignored, so the tracked-file list is the exclusion
|
|
||||||
# rule, and it cannot drift out of date the way EXCLUDES did. It also matches
|
|
||||||
# what this script always claimed to do.
|
|
||||||
#
|
|
||||||
# Untracked-but-not-ignored files are deliberately included: a new doc added in
|
|
||||||
# a working tree should be checked before it is committed, not after.
|
|
||||||
if git rev-parse --git-dir >/dev/null 2>&1; then
|
|
||||||
mapfile -t md_files < <(
|
|
||||||
{ git ls-files -z --cached --others --exclude-standard -- '*.md' | tr '\0' '\n'; } \
|
|
||||||
| sed 's|^|./|' | sort -u
|
|
||||||
)
|
|
||||||
else
|
|
||||||
# Not a git checkout (an exported tarball, say): fall back to walking, with
|
|
||||||
# the prune list below as the only defence.
|
|
||||||
find_args=(. )
|
|
||||||
for pattern in "${EXCLUDES[@]}"; do
|
|
||||||
find_args+=(-path "$pattern" -prune -o)
|
|
||||||
done
|
|
||||||
find_args+=(-name "*.md" -type f -print)
|
|
||||||
mapfile -t md_files < <(find "${find_args[@]}" | sort)
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo " ${#md_files[@]} markdown files"
|
|
||||||
|
|
||||||
broken=""
|
|
||||||
checked=0
|
|
||||||
|
|
||||||
for md in "${md_files[@]}"; do
|
|
||||||
dir="$(dirname "$md")"
|
|
||||||
|
|
||||||
# One documented exception: docs-site/SUMMARY.md is mdBook's table of
|
|
||||||
# contents, and the publish-docs job copies it *into* docs/ before rendering
|
|
||||||
# (book.toml sets src = "../docs"). Its links are therefore written relative
|
|
||||||
# to docs/, not to the directory the file is stored in. Resolving it from
|
|
||||||
# docs/ is what actually validates it — and it is the check that catches a
|
|
||||||
# SUMMARY entry pointing at a page that does not exist, which mdBook itself
|
|
||||||
# only warns about.
|
|
||||||
if [[ "$md" == "./docs-site/SUMMARY.md" ]]; then
|
|
||||||
dir="./docs"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Strip fenced code blocks (``` and ~~~) before extracting links, so sample
|
|
||||||
# markdown shown to the reader is not checked as if it were a live link.
|
|
||||||
# Line numbers are preserved by blanking the lines rather than deleting them.
|
|
||||||
#
|
|
||||||
# Then emit "lineno<TAB>target" for each inline link on each surviving line.
|
|
||||||
while IFS=$'\t' read -r lineno target; do
|
|
||||||
[[ -z "${target:-}" ]] && continue
|
|
||||||
|
|
||||||
# Skip external schemes and pure-anchor links.
|
|
||||||
case "$target" in
|
|
||||||
http://*|https://*|mailto:*|ftp://*|"#"*|"") continue ;;
|
|
||||||
# A protocol-relative or scheme-ish target we do not resolve.
|
|
||||||
//*) continue ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Drop any anchor fragment and query string — we check the path only.
|
|
||||||
path="${target%%#*}"
|
|
||||||
path="${path%%\?*}"
|
|
||||||
[[ -z "$path" ]] && continue
|
|
||||||
|
|
||||||
# Percent-decode: SvelteKit route directories are literally named `[id]`,
|
|
||||||
# which docs link as `%5Bid%5D`, and spaces appear as `%20`.
|
|
||||||
if [[ "$path" == *%* ]]; then
|
|
||||||
path="$(printf '%b' "${path//%/\\x}")"
|
|
||||||
fi
|
|
||||||
|
|
||||||
checked=$((checked + 1))
|
|
||||||
|
|
||||||
if is_generated "$dir/$path"; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -e "$dir/$path" ]]; then
|
|
||||||
broken+="${md}:${lineno} -> ${target}"$'\n'
|
|
||||||
fi
|
|
||||||
done < <(
|
|
||||||
awk '
|
|
||||||
/^[[:space:]]*(```|~~~)/ { fence = !fence; print ""; next }
|
|
||||||
fence { print ""; next }
|
|
||||||
{ print }
|
|
||||||
' "$md" |
|
|
||||||
grep -noE '\]\([^)[:space:]]+' |
|
|
||||||
sed -E 's/^([0-9]+):\]\(/\1\t/'
|
|
||||||
)
|
|
||||||
done
|
|
||||||
|
|
||||||
echo " $checked relative links checked"
|
|
||||||
|
|
||||||
if [[ -n "$broken" ]]; then
|
|
||||||
echo ""
|
|
||||||
echo "❌ Broken documentation links — these targets do not exist on disk:"
|
|
||||||
echo ""
|
|
||||||
echo "$broken" | sed 's/^/ /'
|
|
||||||
echo " Each link is resolved relative to the directory of the file it is in."
|
|
||||||
echo " The usual causes:"
|
|
||||||
echo " • the target file was moved or deleted — update or drop the link;"
|
|
||||||
echo " • the link was written as if the doc lived at the repo root — a doc"
|
|
||||||
echo " in docs/ needs '../' to reach src/, scripts/ or CHANGELOG.md;"
|
|
||||||
echo " • a generated doc emits repo-root-relative hrefs — fix the"
|
|
||||||
echo " generator, not the output (see scripts/extract-traces.ts)."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ All relative documentation links resolve."
|
|
||||||
echo " (Reminder: paths only — anchors and external URLs are NOT checked.)"
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Boundary tripwire: flag domain-taxonomy leaks in the Svelte frontend.
|
|
||||||
#
|
|
||||||
# Implements DR-094 (see docs/requirements.md).
|
|
||||||
#
|
|
||||||
# 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 machine-detectable signature of the leak class 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 array literal naming two or more Jellyfin item types,
|
|
||||||
# ANYWHERE in src/ — 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 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.
|
|
||||||
#
|
|
||||||
# 🔴 What it still CANNOT see (do not read a green run as proof):
|
|
||||||
# - a type set built at run time: [...musicTypes, "Playlist"]
|
|
||||||
# - types split across variables: const A = "Audio"; [A, B]
|
|
||||||
# - taxonomy as control flow: switch (t) { case "Audio": … }
|
|
||||||
# t === "Audio" || t === "MusicAlbum"
|
|
||||||
# - an item type absent from ITEM_TYPES below (false negative by design)
|
|
||||||
#
|
|
||||||
# This check was hardened in July 2026 after the audit found it passing on the
|
|
||||||
# very leak it was written for: the original pattern was anchored to
|
|
||||||
# `includeItemTypes:` at the query site, so assigning the same array to a named
|
|
||||||
# const evaded it entirely (DR-094). The pattern below is the hardened one: it
|
|
||||||
# matches an item-type array literal anywhere, not just at a query site.
|
|
||||||
#
|
|
||||||
# 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 item-type array, 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"
|
|
||||||
|
|
||||||
# Grid styling predicate over `config.itemType`, a value the page already
|
|
||||||
# declares about itself. Selects a *look*, issues no query, and would only
|
|
||||||
# change if the UI were redesigned — presentation, not taxonomy-as-policy.
|
|
||||||
"src/lib/components/library/GenericMediaListPage.svelte"
|
|
||||||
|
|
||||||
# "Is this item a container?" predicate for downloads browsing.
|
|
||||||
# BORDERLINE — leans domain: the container set grows when Jellyfin adds a
|
|
||||||
# container type. TODO: replace with a backend-supplied `MediaItem.isContainer`
|
|
||||||
# flag and remove this entry. Tracked in
|
|
||||||
# a backend-supplied flag; deferred rather than bundled with the tripwire work.
|
|
||||||
"src/lib/components/downloads/DownloadedBrowse.svelte"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Hard cap so erosion is caught mechanically rather than by whoever notices.
|
|
||||||
# Deliberately just above the current count: the next exception forces a
|
|
||||||
# conversation instead of a one-line append.
|
|
||||||
MAX_ALLOWLIST=4
|
|
||||||
|
|
||||||
if [[ "${#ALLOWLIST[@]}" -gt "$MAX_ALLOWLIST" ]]; then
|
|
||||||
echo "❌ Allowlist has ${#ALLOWLIST[@]} entries (max $MAX_ALLOWLIST)."
|
|
||||||
echo " Push taxonomy into Rust instead of appending here."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
is_allowed() {
|
|
||||||
local file="$1"
|
|
||||||
for allowed in "${ALLOWLIST[@]}"; do
|
|
||||||
[[ "$file" == "$allowed" ]] && return 0
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Two or more adjacent Jellyfin item-type string literals inside a bracket.
|
|
||||||
#
|
|
||||||
# NOT anchored to `includeItemTypes:` — that was the original rule, and it missed
|
|
||||||
# the real leak: `searchScope.ts` assigned the same array to a named const and
|
|
||||||
# dereferenced it one indirection away from the query, so the grep never saw it
|
|
||||||
# while CI stayed green. Matching the array literal itself catches a const, a
|
|
||||||
# Record value, a function return, and an inline query alike.
|
|
||||||
#
|
|
||||||
# Deliberate limits:
|
|
||||||
# - requires TWO adjacent types, so single-type presentation
|
|
||||||
# (`itemType: "Movie"`) stays legal — the rule targets *category* taxonomy;
|
|
||||||
# - requires string literals, so `item.type === "Audio"` (display inspection)
|
|
||||||
# does not match;
|
|
||||||
# - uses an explicit type list rather than a generic capitalised-word pattern,
|
|
||||||
# so unrelated string arrays (`["High","Low"]`) produce no noise.
|
|
||||||
#
|
|
||||||
# An item type missing from this list is a false *negative*, never a false
|
|
||||||
# positive — the check degrades safely as Jellyfin adds types.
|
|
||||||
ITEM_TYPES='Movie|Series|Episode|Audio|MusicAlbum|MusicArtist|MusicVideo|Season|BoxSet|Playlist|Book|AudioBook|Video|Person|Folder|CollectionFolder|TvChannel|LiveTvChannel'
|
|
||||||
PATTERN="\[[[:space:]]*\"($ITEM_TYPES)\"[[:space:]]*,[[:space:]]*\"($ITEM_TYPES)\""
|
|
||||||
|
|
||||||
echo "🔎 Checking frontend for domain-taxonomy leaks (item-type array literals)…"
|
|
||||||
|
|
||||||
# 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: an item-type array literal defines a"
|
|
||||||
echo " category in the presentation layer. That taxonomy belongs in Rust —"
|
|
||||||
echo " send an opaque scope/enum and let the backend expand it to item types"
|
|
||||||
echo " (see SearchScope::item_types() in src-tauri/src/repository/types.rs)."
|
|
||||||
echo " Assigning the array to a const does not make it presentation."
|
|
||||||
echo " See docs/specs/scoped-search-boundary.md and CLAUDE.md."
|
|
||||||
echo ""
|
|
||||||
echo "$violations" | sed 's/^/ /'
|
|
||||||
echo " If this is a genuine exception, add the file + reason to ALLOWLIST in"
|
|
||||||
echo " scripts/check-frontend-boundary.sh — but prefer moving it to Rust."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ No multi-type taxonomy queries in the frontend."
|
|
||||||
echo " (Reminder: this is a tripwire, not a proof — the spec-review checklist is"
|
|
||||||
echo " the real gate for subtler leaks.)"
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Refuse to publish a release whose artifacts are not all from this release.
|
|
||||||
#
|
|
||||||
# TRACES: | DR-220
|
|
||||||
#
|
|
||||||
# ./scripts/check-release-artifacts.sh <version> <dir> [<dir>...]
|
|
||||||
#
|
|
||||||
# e.g.
|
|
||||||
# ./scripts/check-release-artifacts.sh v0.9.2 artifacts/linux artifacts/windows
|
|
||||||
#
|
|
||||||
# ## The defect this exists for
|
|
||||||
#
|
|
||||||
# Every JellyTau release from v0.1.0 to v0.8.2 shipped every Windows installer
|
|
||||||
# ever built. `src-tauri/target/*/release/bundle/` is not versioned, cargo never
|
|
||||||
# cleans it, and the CI runner reuses the target directory between builds — so
|
|
||||||
# the copy step's `bundle/**/*-setup.exe` glob collected the whole history. By
|
|
||||||
# v0.8.2 that was sixteen installers, thirteen of them stale. v0.5.0 offered
|
|
||||||
# users a download list going back to 0.1.0.
|
|
||||||
#
|
|
||||||
# Nobody noticed for eight months. There was nothing to notice with: the upload
|
|
||||||
# loop reported success, the assets were real files, and the release page looked
|
|
||||||
# busy rather than wrong.
|
|
||||||
#
|
|
||||||
# The builds now clear the bundle directory first, which removes the cause. This
|
|
||||||
# is the backstop for the next thing that reintroduces a stale file by a route
|
|
||||||
# nobody predicted — a cached directory, a restored artifact, a hand-copied fix.
|
|
||||||
#
|
|
||||||
# ## What it checks
|
|
||||||
#
|
|
||||||
# Every file whose name embeds a semantic version must embed *this* version.
|
|
||||||
# Files with no version in the name (jellytau-release.apk, jellytau.exe,
|
|
||||||
# SHA256SUMS, latest.json) are accepted: they are produced fresh each build and
|
|
||||||
# have no version to disagree with.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if [ "$#" -lt 2 ]; then
|
|
||||||
echo "usage: $0 <version> <dir> [<dir>...]" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
VERSION_RAW="$1"
|
|
||||||
shift
|
|
||||||
# Accept the tag form (v0.9.2) or the bare form (0.9.2).
|
|
||||||
VERSION="${VERSION_RAW#v}"
|
|
||||||
|
|
||||||
echo "🔎 Checking release artifacts are all version ${VERSION}…"
|
|
||||||
|
|
||||||
FOUND=0
|
|
||||||
STALE=0
|
|
||||||
UNVERSIONED=0
|
|
||||||
|
|
||||||
for dir in "$@"; do
|
|
||||||
if [ ! -d "$dir" ]; then
|
|
||||||
echo " (no $dir — skipping)"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# -print0/read -d '' so a filename with a space cannot split into two.
|
|
||||||
while IFS= read -r -d '' file; do
|
|
||||||
name="$(basename "$file")"
|
|
||||||
FOUND=$((FOUND + 1))
|
|
||||||
|
|
||||||
# First x.y.z in the filename, if any.
|
|
||||||
embedded="$(printf '%s' "$name" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)"
|
|
||||||
|
|
||||||
if [ -z "$embedded" ]; then
|
|
||||||
UNVERSIONED=$((UNVERSIONED + 1))
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$embedded" != "$VERSION" ]; then
|
|
||||||
echo " ❌ $name carries version $embedded"
|
|
||||||
STALE=$((STALE + 1))
|
|
||||||
fi
|
|
||||||
done < <(find "$dir" -type f -print0)
|
|
||||||
done
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo " $FOUND file(s) checked; $UNVERSIONED carry no version in the name."
|
|
||||||
|
|
||||||
if [ "$FOUND" -eq 0 ]; then
|
|
||||||
echo "❌ No artifacts found at all. A release with no files is a failed build," >&2
|
|
||||||
echo " not an empty one." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$STALE" -gt 0 ]; then
|
|
||||||
echo ""
|
|
||||||
echo "❌ $STALE artifact(s) belong to a different version than ${VERSION}." >&2
|
|
||||||
echo "" >&2
|
|
||||||
echo " This is how every release from v0.1.0 to v0.8.2 came to ship its" >&2
|
|
||||||
echo " predecessors' Windows installers: src-tauri/target/*/release/bundle/" >&2
|
|
||||||
echo " is never cleaned and the runner reuses it, so a glob picks up" >&2
|
|
||||||
echo " whatever was left behind." >&2
|
|
||||||
echo "" >&2
|
|
||||||
echo " The builds clear that directory first, so seeing this means a stale" >&2
|
|
||||||
echo " file arrived by some other route. Find it before publishing — do not" >&2
|
|
||||||
echo " delete the file and re-run." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ Every versioned artifact is ${VERSION}."
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Refuse tooling that contradicts what this project actually uses.
|
|
||||||
#
|
|
||||||
# TRACES: | DR-222
|
|
||||||
#
|
|
||||||
# ./scripts/check-tooling.sh
|
|
||||||
#
|
|
||||||
# ## Why
|
|
||||||
#
|
|
||||||
# This is a bun project: `packageManager` in package.json says so, bun.lock is
|
|
||||||
# the committed lockfile, and .gitignore hides the other package managers'
|
|
||||||
# lockfiles precisely so they cannot be committed by accident.
|
|
||||||
#
|
|
||||||
# scripts/build-android.sh nonetheless ran `npm install` on its clean-build
|
|
||||||
# path. npm ignores bun.lock, re-resolves the whole tree from package.json, and
|
|
||||||
# writes a package-lock.json that .gitignore then hides from view.
|
|
||||||
#
|
|
||||||
# That is not a style preference. The Tauri CLI refuses to build when a plugin's
|
|
||||||
# Rust crate and npm package differ by minor version, so the JS side is pinned
|
|
||||||
# exactly against Cargo.lock -- and a silent re-resolve is exactly how those
|
|
||||||
# halves drift apart again. The drift already cost one release build.
|
|
||||||
#
|
|
||||||
# It survived because the clean-build path runs rarely. That is the shape of
|
|
||||||
# nearly every defect found while preparing v0.10.0: the code that runs on every
|
|
||||||
# commit was fine, and the code that runs on a release, a clean build or a tag
|
|
||||||
# had no guard at all.
|
|
||||||
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
||||||
cd "$REPO_ROOT" || exit 1
|
|
||||||
|
|
||||||
FAILED=0
|
|
||||||
|
|
||||||
echo "🔎 Checking build tooling is consistent with packageManager…"
|
|
||||||
|
|
||||||
# Only the *invocations* matter. A comment explaining why npm is wrong, or a
|
|
||||||
# .gitignore entry naming package-lock.json, is not a violation -- so match a
|
|
||||||
# command at the start of a line or after a shell separator.
|
|
||||||
PATTERN='(^|[;&|(]|&&|\|\||\bthen |\bdo |[[:space:]]{4,})(npm|yarn|pnpm)[[:space:]]+(install|ci|add|run|exec)\b'
|
|
||||||
|
|
||||||
MATCHES="$(grep -rInE "$PATTERN" \
|
|
||||||
--include='*.sh' --include='*.yml' --include='*.yaml' \
|
|
||||||
scripts/ .gitea/ 2>/dev/null | grep -v '^\s*#' || true)"
|
|
||||||
|
|
||||||
if [ -n "$MATCHES" ]; then
|
|
||||||
echo "❌ A non-bun package manager is invoked:"
|
|
||||||
echo "$MATCHES" | sed 's/^/ /'
|
|
||||||
echo ""
|
|
||||||
echo " This project uses bun (packageManager in package.json, bun.lock"
|
|
||||||
echo " committed). npm/yarn/pnpm ignore that lockfile and re-resolve the"
|
|
||||||
echo " dependency tree, which is how the Tauri plugin crate/package"
|
|
||||||
echo " versions drifted apart and broke a release build."
|
|
||||||
echo ""
|
|
||||||
echo " Use: bun install / bun run / bunx"
|
|
||||||
FAILED=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# A lockfile from another manager should never exist here; .gitignore hides
|
|
||||||
# them, so one can sit in a working tree unnoticed and change what installs.
|
|
||||||
for stray in package-lock.json yarn.lock pnpm-lock.yaml; do
|
|
||||||
if [ -f "$stray" ]; then
|
|
||||||
echo "❌ $stray exists. Another package manager has run here."
|
|
||||||
echo " Delete it and run: bun install"
|
|
||||||
FAILED=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$FAILED" -eq 0 ]; then
|
|
||||||
echo "✅ Only bun is used, and no foreign lockfile is present."
|
|
||||||
fi
|
|
||||||
|
|
||||||
exit "$FAILED"
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Clean build artifacts
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "🧹 Cleaning build artifacts..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Clean frontend
|
|
||||||
if [ -d "node_modules/.cache" ]; then
|
|
||||||
echo "Cleaning Vite cache..."
|
|
||||||
rm -rf node_modules/.cache
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -d ".svelte-kit" ]; then
|
|
||||||
echo "Cleaning SvelteKit build..."
|
|
||||||
rm -rf .svelte-kit
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -d "build" ]; then
|
|
||||||
echo "Cleaning build directory..."
|
|
||||||
rm -rf build
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Clean Rust
|
|
||||||
echo "Cleaning Rust target..."
|
|
||||||
cd src-tauri
|
|
||||||
cargo clean
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
# Clean Android
|
|
||||||
if [ -d "src-tauri/gen/android" ]; then
|
|
||||||
echo "Cleaning Android build..."
|
|
||||||
cd src-tauri/gen/android
|
|
||||||
./gradlew clean 2>/dev/null || true
|
|
||||||
cd ../../..
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "✅ Clean complete!"
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Deploy APK to connected Android device
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "📱 Deploying to Android device..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check if device is connected
|
|
||||||
if ! adb devices | grep -q "device$"; then
|
|
||||||
echo "❌ No Android device connected!"
|
|
||||||
echo "Please connect a device or start an emulator."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Build type: debug or release (default: debug). `--debug` alongside `release`
|
|
||||||
# means the side-by-side release build — same APK path, but it was packaged
|
|
||||||
# under the .debug applicationId, so the package to launch differs.
|
|
||||||
BUILD_TYPE="debug"
|
|
||||||
SIDE_BY_SIDE=0
|
|
||||||
for arg in "$@"; do
|
|
||||||
case "$arg" in
|
|
||||||
--debug|--side-by-side) SIDE_BY_SIDE=1 ;;
|
|
||||||
debug|release) BUILD_TYPE="$arg" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
[ "$BUILD_TYPE" = "debug" ] && SIDE_BY_SIDE=1
|
|
||||||
|
|
||||||
# The .debug applicationId (see src-tauri/android/app/build.gradle.kts) is a
|
|
||||||
# separate package, so it installs alongside a real release build — no
|
|
||||||
# uninstall dance needed.
|
|
||||||
if [ "$BUILD_TYPE" = "release" ]; then
|
|
||||||
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk"
|
|
||||||
else
|
|
||||||
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$SIDE_BY_SIDE" = "1" ]; then
|
|
||||||
APP_PACKAGE="com.dtourolle.jellytau.debug"
|
|
||||||
else
|
|
||||||
APP_PACKAGE="com.dtourolle.jellytau"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if APK exists
|
|
||||||
if [ ! -f "$APK_PATH" ]; then
|
|
||||||
echo "❌ APK not found at: $APK_PATH"
|
|
||||||
if [ "$BUILD_TYPE" = "release" ] && [ "$SIDE_BY_SIDE" = "1" ]; then
|
|
||||||
echo "Run './scripts/build-android.sh release --debug' first"
|
|
||||||
else
|
|
||||||
echo "Run './scripts/build-android.sh $BUILD_TYPE' first"
|
|
||||||
fi
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "📦 Installing APK: $APK_PATH"
|
|
||||||
echo "📛 Package: $APP_PACKAGE"
|
|
||||||
|
|
||||||
if ! adb install -r "$APK_PATH"; then
|
|
||||||
echo ""
|
|
||||||
echo "❌ Install failed."
|
|
||||||
echo " If it says INSTALL_FAILED_UPDATE_INCOMPATIBLE, an older build of"
|
|
||||||
echo " '$APP_PACKAGE' signed with a different key is still installed."
|
|
||||||
echo " Uninstall just that one and retry:"
|
|
||||||
echo " adb uninstall $APP_PACKAGE"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "✅ Deployment complete!"
|
|
||||||
echo "🚀 Launching..."
|
|
||||||
adb shell monkey -p "$APP_PACKAGE" -c android.intent.category.LAUNCHER 1 > /dev/null 2>&1 \
|
|
||||||
|| echo " (auto-launch failed — start it from the launcher)"
|
|
||||||
@@ -1,406 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for the traceability coverage computation.
|
|
||||||
*
|
|
||||||
* These run over fixture strings rather than the live docs/requirements.md, so
|
|
||||||
* their meaning does not drift as requirements are added.
|
|
||||||
*
|
|
||||||
* Background: the CI gate divided traced-requirement counts by hardcoded
|
|
||||||
* denominators (UR/39, IR/24, DR/48, JA/3, total 114) that had fallen out of
|
|
||||||
* date, reporting 158% coverage and making the 50% threshold unreachable. These
|
|
||||||
* tests pin the parsing and arithmetic that replace those literals.
|
|
||||||
*
|
|
||||||
* @req-test: UT-089 - Requirement definitions parsed from requirements.md
|
|
||||||
* @req-test: UT-090 - Coverage is the intersection of traced and defined IDs
|
|
||||||
* @req-test: UT-202 - Generated matrix links resolve from docs/
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import * as fs from "fs";
|
|
||||||
import * as path from "path";
|
|
||||||
import {
|
|
||||||
countDefinedRequirements,
|
|
||||||
computeCoverage,
|
|
||||||
findDanglingIds,
|
|
||||||
formatMatrixFileLink,
|
|
||||||
generateMarkdown,
|
|
||||||
isTracedSourceFile,
|
|
||||||
MIN_COVERAGE_PERCENT,
|
|
||||||
type TracesData,
|
|
||||||
} from "./extract-traces";
|
|
||||||
|
|
||||||
// import.meta.dir is Bun-only; derive from import.meta.url under vitest.
|
|
||||||
const HERE = path.dirname(new URL(import.meta.url).pathname);
|
|
||||||
|
|
||||||
describe("isTracedSourceFile", () => {
|
|
||||||
// The extractor used to accept only .ts/.svelte/.rs under src/, src-tauri/src/
|
|
||||||
// and scripts/. Every requirement implemented by *configuration* was therefore
|
|
||||||
// invisible to the matrix that measures it: eslint.config.js (DR-205), the
|
|
||||||
// pre-commit hook (DR-207), rust-toolchain.toml (DR-206) and deny.toml
|
|
||||||
// (DR-216) all carry TRACES comments that were never read. Each one counted
|
|
||||||
// against coverage as an uncovered requirement while being, in fact, covered.
|
|
||||||
it("accepts the source extensions it always did", () => {
|
|
||||||
expect(isTracedSourceFile("src/lib/utils/logger.ts")).toBe(true);
|
|
||||||
expect(isTracedSourceFile("src/routes/settings/+page.svelte")).toBe(true);
|
|
||||||
expect(isTracedSourceFile("src-tauri/src/lib.rs")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts tooling files that implement a requirement", () => {
|
|
||||||
expect(isTracedSourceFile("eslint.config.js")).toBe(true);
|
|
||||||
expect(isTracedSourceFile("src-tauri/deny.toml")).toBe(true);
|
|
||||||
expect(isTracedSourceFile("src-tauri/rust-toolchain.toml")).toBe(true);
|
|
||||||
expect(isTracedSourceFile("scripts/hooks/pre-commit")).toBe(true);
|
|
||||||
// Shell tooling is listed individually, not globbed: most scripts/*.sh
|
|
||||||
// implement nothing, and adding one should be a decision.
|
|
||||||
expect(isTracedSourceFile("scripts/check-release-artifacts.sh")).toBe(true);
|
|
||||||
expect(isTracedSourceFile("scripts/build-desktop-linux.sh")).toBe(true);
|
|
||||||
expect(isTracedSourceFile("scripts/logcat.sh")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not scan CI workflows, whose comments discuss TRACES in prose", () => {
|
|
||||||
// .gitea/workflows/traceability-check.yml explains the gate, so it contains
|
|
||||||
// lines like "a `TRACES:` comment ... (DR-189 and UT-188 lived in three
|
|
||||||
// source files, defined nowhere)". The extractor's pattern would read that
|
|
||||||
// as a trace and manufacture references to IDs that do not exist, failing
|
|
||||||
// traces:validate. A file that *describes* traceability is not a file that
|
|
||||||
// implements a requirement.
|
|
||||||
expect(isTracedSourceFile(".gitea/workflows/traceability-check.yml")).toBe(false);
|
|
||||||
expect(isTracedSourceFile(".gitea/workflows/build-and-test.yml")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects files that merely mention a requirement in prose", () => {
|
|
||||||
// requirements.md defines IDs; traceability.md is generated *from* traces.
|
|
||||||
// Scanning either would make every requirement trace to itself.
|
|
||||||
expect(isTracedSourceFile("docs/requirements.md")).toBe(false);
|
|
||||||
expect(isTracedSourceFile("docs/traceability.md")).toBe(false);
|
|
||||||
expect(isTracedSourceFile("README.md")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects generated and vendored trees", () => {
|
|
||||||
expect(isTracedSourceFile("node_modules/foo/index.ts")).toBe(false);
|
|
||||||
expect(isTracedSourceFile("src-tauri/target/debug/build/x.rs")).toBe(false);
|
|
||||||
expect(isTracedSourceFile("src-tauri/gen/android/app/build.gradle.kts")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("countDefinedRequirements", () => {
|
|
||||||
it("counts a well-formed table row as a defined requirement", () => {
|
|
||||||
const md = `
|
|
||||||
| ID | Requirement | Priority | Status |
|
|
||||||
|----|-------------|----------|--------|
|
|
||||||
| UR-001 | Run the app on multiple platforms | High | In Progress |
|
|
||||||
| UR-002 | Access media when online or offline | High | Done |
|
|
||||||
`;
|
|
||||||
const defined = countDefinedRequirements(md);
|
|
||||||
expect(defined.UR).toBe(2);
|
|
||||||
expect(defined.DR).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not count IDs that appear only in the Traces To column", () => {
|
|
||||||
// The bug this rule avoids: a naive grep for /DR-\d{3}/ over the whole file
|
|
||||||
// counts DR-001 here as "defined", inflating the denominator with IDs that
|
|
||||||
// are merely referenced.
|
|
||||||
const md = `
|
|
||||||
| DR-001 | Player state machine | Player | UR-005 | Done |
|
|
||||||
| DR-002 | MediaItem struct | Player | UR-003, UR-004 | Done |
|
|
||||||
`;
|
|
||||||
const defined = countDefinedRequirements(md);
|
|
||||||
expect(defined.DR).toBe(2);
|
|
||||||
// UR-005/UR-003/UR-004 are referenced, never defined here.
|
|
||||||
expect(defined.UR).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not count IDs mentioned in prose", () => {
|
|
||||||
const md = `
|
|
||||||
Some prose explaining that UR-005 relates to DR-001 and JA-002.
|
|
||||||
|
|
||||||
| UR-005 | Control media playback | High | Done |
|
|
||||||
`;
|
|
||||||
const defined = countDefinedRequirements(md);
|
|
||||||
expect(defined.UR).toBe(1);
|
|
||||||
expect(defined.DR).toBe(0);
|
|
||||||
expect(defined.JA).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deduplicates an ID listed in both the spec table and the traceability matrix", () => {
|
|
||||||
// requirements.md lists every UR twice: once in §1 (definition) and again in
|
|
||||||
// §3 (traceability matrix), both as a leading table cell. Counting rows
|
|
||||||
// instead of unique IDs double-counts the UR denominator (121 vs 61).
|
|
||||||
const md = `
|
|
||||||
| UR-005 | Control media playback | High | Done |
|
|
||||||
| UR-006 | Browse the library | High | Done |
|
|
||||||
|
|
||||||
### Traceability Matrix
|
|
||||||
|
|
||||||
| UR-005 | - | DR-001, DR-005, DR-009 |
|
|
||||||
| UR-006 | - | DR-012 |
|
|
||||||
`;
|
|
||||||
const defined = countDefinedRequirements(md);
|
|
||||||
expect(defined.UR).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("collects the defined ID set, not just counts", () => {
|
|
||||||
const md = `
|
|
||||||
| UR-001 | A | High | Done |
|
|
||||||
| DR-050 | B | Player | UR-001 | Done |
|
|
||||||
`;
|
|
||||||
const defined = countDefinedRequirements(md);
|
|
||||||
expect(defined.ids.has("UR-001")).toBe(true);
|
|
||||||
expect(defined.ids.has("DR-050")).toBe(true);
|
|
||||||
expect(defined.ids.has("UR-999")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("collects UT/IT rows separately, out of the coverage denominator", () => {
|
|
||||||
// §4 defines the test taxonomy. Those rows must be known (so a TRACES
|
|
||||||
// comment may name them) without ever moving the coverage ratio.
|
|
||||||
const md = `
|
|
||||||
| UR-001 | A | High | Done |
|
|
||||||
| UT-001 | Player state transitions | DR-001 | Pending |
|
|
||||||
| IT-004 | Playback end-to-end | DR-002 | Pending |
|
|
||||||
`;
|
|
||||||
const defined = countDefinedRequirements(md);
|
|
||||||
expect(defined.total).toBe(1);
|
|
||||||
expect(defined.ids.has("UT-001")).toBe(false);
|
|
||||||
expect(defined.testIds.has("UT-001")).toBe(true);
|
|
||||||
expect(defined.testIds.has("IT-004")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("findDanglingIds", () => {
|
|
||||||
const defined = {
|
|
||||||
UR: 1,
|
|
||||||
IR: 0,
|
|
||||||
DR: 1,
|
|
||||||
JA: 0,
|
|
||||||
total: 2,
|
|
||||||
ids: new Set(["UR-001", "DR-001"]),
|
|
||||||
testIds: new Set(["UT-001"]),
|
|
||||||
};
|
|
||||||
|
|
||||||
it("flags a requirement ID that requirements.md does not define", () => {
|
|
||||||
expect(findDanglingIds(["UR-001", "DR-189"], defined)).toEqual(["DR-189"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flags an undefined UT/IT id, which the coverage orphan list cannot", () => {
|
|
||||||
// The gap this closes: computeCoverage deliberately ignores UT/IT, so
|
|
||||||
// UT-188 sat in three source files, defined nowhere, entirely unreported.
|
|
||||||
expect(computeCoverage(["UT-188"], defined).orphaned).toEqual([]);
|
|
||||||
expect(findDanglingIds(["UT-188"], defined)).toEqual(["UT-188"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts every ID that is defined, requirement or test", () => {
|
|
||||||
expect(findDanglingIds(["UR-001", "DR-001", "UT-001"], defined)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deduplicates and sorts, so one typo is reported once", () => {
|
|
||||||
expect(findDanglingIds(["DR-189", "DR-189", "UR-999", "DR-189"], defined)).toEqual([
|
|
||||||
"DR-189",
|
|
||||||
"UR-999",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores IDs whose prefix is not a known trace type", () => {
|
|
||||||
// e.g. an unrelated "AB-123" caught by the loose ID regex.
|
|
||||||
expect(findDanglingIds(["AB-123"], defined)).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("coverage threshold", () => {
|
|
||||||
it("matches MIN_THRESHOLD in the Gitea traceability workflow", () => {
|
|
||||||
// Two files must agree on the gate: the script (local `traces:coverage`)
|
|
||||||
// and the workflow. Drift means the local gate and CI disagree about what
|
|
||||||
// passes, which is how the 50%-while-actually-86% slack went unnoticed.
|
|
||||||
const workflow = fs.readFileSync(
|
|
||||||
path.resolve(HERE, "../.gitea/workflows/traceability-check.yml"),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
const match = workflow.match(/^\s*MIN_THRESHOLD=(\d+)\s*$/m);
|
|
||||||
expect(match).not.toBeNull();
|
|
||||||
expect(Number(match![1])).toBe(MIN_COVERAGE_PERCENT);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is a ratchet: never lower it to make a red build pass", () => {
|
|
||||||
// Sanity bound. If coverage genuinely climbs, raise both numbers together.
|
|
||||||
expect(MIN_COVERAGE_PERCENT).toBeGreaterThanOrEqual(82);
|
|
||||||
expect(MIN_COVERAGE_PERCENT).toBeLessThanOrEqual(100);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("computeCoverage", () => {
|
|
||||||
const defined = {
|
|
||||||
UR: 2,
|
|
||||||
IR: 0,
|
|
||||||
DR: 2,
|
|
||||||
JA: 0,
|
|
||||||
total: 4,
|
|
||||||
ids: new Set(["UR-001", "UR-002", "DR-001", "DR-002"]),
|
|
||||||
testIds: new Set<string>(),
|
|
||||||
};
|
|
||||||
|
|
||||||
it("computes coverage as traced ∩ defined over defined", () => {
|
|
||||||
const traced = ["UR-001", "DR-001"];
|
|
||||||
const cov = computeCoverage(traced, defined);
|
|
||||||
expect(cov.covered).toBe(2);
|
|
||||||
expect(cov.total).toBe(4);
|
|
||||||
expect(cov.percent).toBe(50);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not let a traced-but-undefined ID inflate the numerator", () => {
|
|
||||||
// This is how a ratio exceeds 100%: a TRACES comment naming a typo'd or
|
|
||||||
// deleted requirement counted as covered.
|
|
||||||
const traced = ["UR-001", "DR-001", "DR-097"];
|
|
||||||
const cov = computeCoverage(traced, defined);
|
|
||||||
expect(cov.covered).toBe(2);
|
|
||||||
expect(cov.percent).toBe(50);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports traced-but-undefined IDs as orphaned so they get fixed", () => {
|
|
||||||
const traced = ["UR-001", "DR-097", "JA-404"];
|
|
||||||
const cov = computeCoverage(traced, defined);
|
|
||||||
expect(cov.orphaned).toEqual(["DR-097", "JA-404"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("has no orphans when every traced ID is defined", () => {
|
|
||||||
const cov = computeCoverage(["UR-001", "UR-002"], defined);
|
|
||||||
expect(cov.orphaned).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores UT/IT test IDs entirely — they are a separate taxonomy", () => {
|
|
||||||
// UT/IT are defined in §4 of requirements.md, not among the four
|
|
||||||
// requirement types. Treating them as orphans buries real typos in ~60
|
|
||||||
// lines of noise, and counting them would corrupt the ratio.
|
|
||||||
const cov = computeCoverage(["UR-001", "UT-088", "IT-017"], defined);
|
|
||||||
expect(cov.orphaned).toEqual([]);
|
|
||||||
expect(cov.covered).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports 0% rather than dividing by zero for an empty trace set", () => {
|
|
||||||
const cov = computeCoverage([], defined);
|
|
||||||
expect(cov.covered).toBe(0);
|
|
||||||
expect(cov.percent).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports 0% rather than NaN when nothing is defined", () => {
|
|
||||||
const empty = {
|
|
||||||
UR: 0,
|
|
||||||
IR: 0,
|
|
||||||
DR: 0,
|
|
||||||
JA: 0,
|
|
||||||
total: 0,
|
|
||||||
ids: new Set<string>(),
|
|
||||||
testIds: new Set<string>(),
|
|
||||||
};
|
|
||||||
const cov = computeCoverage([], empty);
|
|
||||||
expect(cov.percent).toBe(0);
|
|
||||||
expect(Number.isNaN(cov.percent)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports exactly 100% when all defined requirements are traced, never above", () => {
|
|
||||||
const traced = ["UR-001", "UR-002", "DR-001", "DR-002"];
|
|
||||||
const cov = computeCoverage(traced, defined);
|
|
||||||
expect(cov.percent).toBe(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores duplicate traced IDs", () => {
|
|
||||||
const traced = ["UR-001", "UR-001", "UR-001"];
|
|
||||||
const cov = computeCoverage(traced, defined);
|
|
||||||
expect(cov.covered).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("generated matrix file links", () => {
|
|
||||||
// Regression: the generator emitted the repo-root-relative path as the href
|
|
||||||
// (`](src-tauri/src/…)`), but writes its output to docs/traceability.md — so
|
|
||||||
// every one of the ~2,800 links resolved to docs/src-tauri/… and 404'd, in
|
|
||||||
// the repo browser and on the published mdBook site. The markdown generator
|
|
||||||
// had no test at all, which is why it survived. UT-202.
|
|
||||||
//
|
|
||||||
// @req-test: UT-202
|
|
||||||
|
|
||||||
/** A minimal TracesData whose single entry points at a file that really exists. */
|
|
||||||
function fixture(file: string, line = 12): TracesData {
|
|
||||||
return {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
totalFiles: 1,
|
|
||||||
totalTraces: 1,
|
|
||||||
requirements: {
|
|
||||||
"DR-093": [{ file, line, context: "export function x() {}" }],
|
|
||||||
},
|
|
||||||
byType: { UR: [], IR: [], DR: ["DR-093"], JA: [] },
|
|
||||||
} as TracesData;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pull the href out of the first `- **File:** [`x`](href)` line. */
|
|
||||||
function firstHref(md: string): string {
|
|
||||||
const m = md.match(/^- \*\*File:\*\* \[`[^`]+`\]\(([^)]+)\)/m);
|
|
||||||
expect(m).not.toBeNull();
|
|
||||||
return m![1];
|
|
||||||
}
|
|
||||||
|
|
||||||
it("emits an href that resolves, from docs/, to a file that exists", () => {
|
|
||||||
// Use a real repo file so "exists on disk" is a genuine assertion.
|
|
||||||
const target = "scripts/extract-traces.ts";
|
|
||||||
const md = generateMarkdown(fixture(target));
|
|
||||||
|
|
||||||
const href = firstHref(md);
|
|
||||||
const [relPath] = href.split("#");
|
|
||||||
|
|
||||||
// traceability.md is written to docs/, so links resolve from there.
|
|
||||||
const resolved = path.resolve(HERE, "../docs", relPath);
|
|
||||||
expect(fs.existsSync(resolved)).toBe(true);
|
|
||||||
expect(resolved).toBe(path.resolve(HERE, "..", target));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the repo-root-relative path as the visible link text", () => {
|
|
||||||
// The text is what a developer copies into an editor or a grep; only the
|
|
||||||
// href is rewritten for the docs/ location.
|
|
||||||
const md = generateMarkdown(fixture("src-tauri/src/lib.rs"));
|
|
||||||
expect(md).toContain("[`src-tauri/src/lib.rs`]");
|
|
||||||
expect(md).not.toContain("[`../src-tauri/src/lib.rs`]");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the #Lnn line anchor on the href", () => {
|
|
||||||
const link = formatMatrixFileLink("scripts/extract-traces.ts", 427);
|
|
||||||
expect(link).toBe("[`scripts/extract-traces.ts`](../scripts/extract-traces.ts#L427)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not produce a bare repo-root href, which resolves to docs/<path>", () => {
|
|
||||||
const md = generateMarkdown(fixture("scripts/extract-traces.ts"));
|
|
||||||
const href = firstHref(md);
|
|
||||||
expect(href.startsWith("../")).toBe(true);
|
|
||||||
// The pre-fix output — the exact shape that produced docs/scripts/….
|
|
||||||
expect(href.startsWith("scripts/")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("live requirements.md", () => {
|
|
||||||
it("parses the real file into a self-consistent denominator", () => {
|
|
||||||
// Guards the original regression: CI hardcoded UR/39, IR/24, DR/48, JA/3
|
|
||||||
// (total 114) while the real file had grown past 200, so the gate compared
|
|
||||||
// live traces against a frozen denominator and reported 158% coverage.
|
|
||||||
//
|
|
||||||
// Deliberately asserts *invariants*, not exact totals. Pinning the counts
|
|
||||||
// was tried and turned this test into a merge-conflict magnet: every
|
|
||||||
// requirement added on any branch had to edit the numbers here too, and the
|
|
||||||
// comment above them grew into a ledger of which branch contributed which
|
|
||||||
// row. Worse, the pins never guarded the actual defect — a stale denominator
|
|
||||||
// is caught by the sum-consistency check below, and the >100% ratio it
|
|
||||||
// produced is covered directly by the computeCoverage tests, on fixtures.
|
|
||||||
const md = fs.readFileSync(path.resolve(HERE, "../docs/requirements.md"), "utf-8");
|
|
||||||
const defined = countDefinedRequirements(md);
|
|
||||||
|
|
||||||
// The parser found real rows of every type: a section silently failing to
|
|
||||||
// parse would shrink the denominator and inflate coverage.
|
|
||||||
expect(defined.UR).toBeGreaterThan(0);
|
|
||||||
expect(defined.IR).toBeGreaterThan(0);
|
|
||||||
expect(defined.DR).toBeGreaterThan(0);
|
|
||||||
expect(defined.JA).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// The denominator is the sum of its parts, and every counted id is unique —
|
|
||||||
// double-counting one section is the other way a ratio breaks.
|
|
||||||
expect(defined.total).toBe(defined.UR + defined.IR + defined.DR + defined.JA);
|
|
||||||
expect(defined.ids.size).toBe(defined.total);
|
|
||||||
|
|
||||||
// The file is live, not frozen: it is well past the 114 the stale gate used.
|
|
||||||
expect(defined.total).toBeGreaterThan(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,645 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
/**
|
|
||||||
* Extract TRACES from source code and generate requirement mapping
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* bun run scripts/extract-traces.ts
|
|
||||||
* bun run scripts/extract-traces.ts --format json
|
|
||||||
* bun run scripts/extract-traces.ts --format markdown > docs/traceability.md
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as fs from "fs";
|
|
||||||
import * as path from "path";
|
|
||||||
import { execSync } from "child_process";
|
|
||||||
|
|
||||||
interface TraceEntry {
|
|
||||||
file: string;
|
|
||||||
line: number;
|
|
||||||
context: string;
|
|
||||||
requirements: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RequirementMapping {
|
|
||||||
[reqId: string]: TraceEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TracesData {
|
|
||||||
timestamp: string;
|
|
||||||
totalFiles: number;
|
|
||||||
totalTraces: number;
|
|
||||||
requirements: RequirementMapping;
|
|
||||||
byType: {
|
|
||||||
UR: string[];
|
|
||||||
IR: string[];
|
|
||||||
DR: string[];
|
|
||||||
JA: string[];
|
|
||||||
};
|
|
||||||
/** Requirements *defined* in requirements.md — the coverage denominators. */
|
|
||||||
defined?: { UR: number; IR: number; DR: number; JA: number; total: number };
|
|
||||||
coverage?: CoverageResult;
|
|
||||||
/** Traced IDs of any type that requirements.md does not define. */
|
|
||||||
dangling?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimum overall requirement coverage the traceability gate accepts.
|
|
||||||
*
|
|
||||||
* **Ratchet policy: this number only ever goes up.** It is set a few points
|
|
||||||
* below the coverage actually achieved, so a real regression trips it instead of
|
|
||||||
* being absorbed by slack. It sat at 50 while true coverage was 86%, which meant
|
|
||||||
* half the matrix could rot before CI noticed. When coverage rises durably,
|
|
||||||
* raise this to sit just under the new figure. Do **not** lower it to make a
|
|
||||||
* failing build pass — add the missing TRACES comments instead.
|
|
||||||
*
|
|
||||||
* `.gitea/workflows/traceability-check.yml` carries the same number as
|
|
||||||
* `MIN_THRESHOLD`; `scripts/extract-traces.test.ts` fails if the two drift.
|
|
||||||
*
|
|
||||||
* TRACES: | DR-093
|
|
||||||
*/
|
|
||||||
export const MIN_COVERAGE_PERCENT = 89;
|
|
||||||
|
|
||||||
// Repo root, derived from this script's location (scripts/ -> repo root).
|
|
||||||
// Must NOT be hardcoded to a developer's machine, or CI checkouts see no files.
|
|
||||||
//
|
|
||||||
// `import.meta.dir` is a Bun extension and is undefined when this module is
|
|
||||||
// imported by vitest (which runs it as an ordinary ESM module), so fall back to
|
|
||||||
// import.meta.url — this file must stay importable for extract-traces.test.ts.
|
|
||||||
const SCRIPT_DIR = import.meta.dir ?? path.dirname(new URL(import.meta.url).pathname);
|
|
||||||
const BASE_DIR = path.resolve(SCRIPT_DIR, "..");
|
|
||||||
|
|
||||||
const TRACES_PATTERN = /TRACES:\s*([^\n]+)/gi;
|
|
||||||
const REQ_ID_PATTERN = /([A-Z]{2})-(\d{3})/g;
|
|
||||||
|
|
||||||
function extractRequirementIds(tracesString: string): string[] {
|
|
||||||
const matches = [...tracesString.matchAll(REQ_ID_PATTERN)];
|
|
||||||
return matches.map((m) => `${m[1]}-${m[2]}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tooling files that implement a requirement.
|
|
||||||
*
|
|
||||||
* The walker below only visits `src/`, `src-tauri/src/` and `scripts/`, and only
|
|
||||||
* picks up `.ts`/`.svelte`/`.rs`. That made every requirement implemented by
|
|
||||||
* *configuration* invisible to the matrix that measures it — DR-205
|
|
||||||
* (eslint.config.js), DR-206 (rust-toolchain.toml), DR-207 (the pre-commit
|
|
||||||
* hook) and DR-216 (deny.toml) all carry TRACES comments that nothing read, so
|
|
||||||
* each was counted as uncovered while being covered.
|
|
||||||
*
|
|
||||||
* An explicit list rather than "also scan .toml/.js/.yml": most config files in
|
|
||||||
* this repo implement nothing, and one class of file is actively dangerous to
|
|
||||||
* scan — see `isTracedSourceFile`.
|
|
||||||
*/
|
|
||||||
const TOOLING_FILES = new Set([
|
|
||||||
"eslint.config.js",
|
|
||||||
"vitest.config.ts",
|
|
||||||
"scripts/hooks/pre-commit",
|
|
||||||
"src-tauri/deny.toml",
|
|
||||||
"src-tauri/rust-toolchain.toml",
|
|
||||||
// Shell tooling that implements a requirement. Named individually rather than
|
|
||||||
// globbing scripts/*.sh: most of these scripts implement nothing, and the
|
|
||||||
// point of the list is that adding a file is a decision.
|
|
||||||
"scripts/install-hooks.sh",
|
|
||||||
"scripts/check-release-artifacts.sh",
|
|
||||||
"scripts/build-desktop-linux.sh",
|
|
||||||
"scripts/build-windows-cross.sh",
|
|
||||||
"scripts/restore-ownership.sh",
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** Directory names that never contain hand-written traced source. */
|
|
||||||
const EXCLUDED_SEGMENTS = new Set([
|
|
||||||
"node_modules",
|
|
||||||
"target",
|
|
||||||
"build",
|
|
||||||
".git",
|
|
||||||
".svelte-kit",
|
|
||||||
"docs-site",
|
|
||||||
// Tauri regenerates src-tauri/gen/ on every android/desktop init; the
|
|
||||||
// canonical Android sources live in src-tauri/android/ and are synced into it.
|
|
||||||
"gen",
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decide whether a repo-relative path should be scanned for TRACES comments.
|
|
||||||
*
|
|
||||||
* Exported for scripts/extract-traces.test.ts — the file-walking half needs a
|
|
||||||
* filesystem, this half is a pure decision and is where the mistakes live.
|
|
||||||
*
|
|
||||||
* Deliberately excluded:
|
|
||||||
* - `docs/requirements.md` *defines* IDs and `docs/traceability.md` is
|
|
||||||
* generated from traces; scanning either would make requirements trace to
|
|
||||||
* themselves.
|
|
||||||
* - `.gitea/workflows/*.yml` — traceability-check.yml explains the gate in
|
|
||||||
* prose, quoting "a `TRACES:` comment" on the same line as example IDs that
|
|
||||||
* are deliberately undefined. The extractor would read those as real traces
|
|
||||||
* and then fail its own dangling-ID check.
|
|
||||||
*/
|
|
||||||
export function isTracedSourceFile(relativePath: string): boolean {
|
|
||||||
const p = relativePath.split(path.sep).join("/");
|
|
||||||
if (p.split("/").some((segment) => EXCLUDED_SEGMENTS.has(segment))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (TOOLING_FILES.has(p)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const isSourceExtension = p.endsWith(".ts") || p.endsWith(".svelte") || p.endsWith(".rs");
|
|
||||||
if (!isSourceExtension) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return p.startsWith("src/") || p.startsWith("src-tauri/src/") || p.startsWith("scripts/");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAllSourceFiles(): string[] {
|
|
||||||
const baseDir = BASE_DIR;
|
|
||||||
// `scripts` is scanned too: build tooling implements requirements (e.g.
|
|
||||||
// DR-093, the coverage engine itself) and would otherwise be invisible to the
|
|
||||||
// very matrix it generates.
|
|
||||||
const patterns = ["src", "src-tauri/src", "scripts"];
|
|
||||||
const files: string[] = [];
|
|
||||||
|
|
||||||
function walkDir(dir: string) {
|
|
||||||
try {
|
|
||||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(dir, entry.name);
|
|
||||||
const relativePath = path.relative(baseDir, fullPath);
|
|
||||||
|
|
||||||
// Directory pruning still happens here so the walk does not descend
|
|
||||||
// into node_modules/target at all; isTracedSourceFile repeats the rule
|
|
||||||
// for individual files (and is the version under test).
|
|
||||||
if (entry.isDirectory() && !isTracedSourceFile(path.join(relativePath, "x.ts"))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
walkDir(fullPath);
|
|
||||||
} else if (isTracedSourceFile(relativePath)) {
|
|
||||||
files.push(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Skip directories we can't read
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const pattern of patterns) {
|
|
||||||
const dir = path.join(baseDir, pattern);
|
|
||||||
if (fs.existsSync(dir)) {
|
|
||||||
walkDir(dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint.config.js, deny.toml and rust-toolchain.toml sit at the repo root or
|
|
||||||
// in src-tauri/ rather than under a walked root, so they are added by name.
|
|
||||||
for (const toolingFile of TOOLING_FILES) {
|
|
||||||
const fullPath = path.join(baseDir, toolingFile);
|
|
||||||
if (fs.existsSync(fullPath) && !files.includes(fullPath)) {
|
|
||||||
files.push(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTraces(): TracesData {
|
|
||||||
const requirementMap: RequirementMapping = {};
|
|
||||||
const byType: Record<string, Set<string>> = {
|
|
||||||
UR: new Set(),
|
|
||||||
IR: new Set(),
|
|
||||||
DR: new Set(),
|
|
||||||
JA: new Set(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let totalTraces = 0;
|
|
||||||
const baseDir = BASE_DIR;
|
|
||||||
|
|
||||||
const files = getAllSourceFiles();
|
|
||||||
|
|
||||||
for (const fullPath of files) {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(fullPath, "utf-8");
|
|
||||||
const lines = content.split("\n");
|
|
||||||
const relativePath = path.relative(baseDir, fullPath);
|
|
||||||
|
|
||||||
let match;
|
|
||||||
TRACES_PATTERN.lastIndex = 0;
|
|
||||||
|
|
||||||
while ((match = TRACES_PATTERN.exec(content)) !== null) {
|
|
||||||
const tracesStr = match[1];
|
|
||||||
const reqIds = extractRequirementIds(tracesStr);
|
|
||||||
|
|
||||||
if (reqIds.length === 0) continue;
|
|
||||||
|
|
||||||
// Find line number
|
|
||||||
const beforeMatch = content.substring(0, match.index);
|
|
||||||
const lineNum = beforeMatch.split("\n").length - 1;
|
|
||||||
|
|
||||||
// Get context (function/class name if available)
|
|
||||||
let context = "Unknown";
|
|
||||||
for (let i = lineNum; i >= Math.max(0, lineNum - 10); i--) {
|
|
||||||
const line = lines[i];
|
|
||||||
if (
|
|
||||||
line.includes("function ") ||
|
|
||||||
line.includes("export const ") ||
|
|
||||||
line.includes("pub fn ") ||
|
|
||||||
line.includes("pub enum ") ||
|
|
||||||
line.includes("pub struct ") ||
|
|
||||||
line.includes("impl ") ||
|
|
||||||
line.includes("async function ") ||
|
|
||||||
line.includes("class ") ||
|
|
||||||
line.includes("export type ")
|
|
||||||
) {
|
|
||||||
context = line
|
|
||||||
.trim()
|
|
||||||
.replace(/^\s*\/\/\s*/, "")
|
|
||||||
.replace(/^\s*\/\*\*\s*/, "");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry: TraceEntry = {
|
|
||||||
file: relativePath,
|
|
||||||
line: lineNum + 1,
|
|
||||||
context,
|
|
||||||
requirements: reqIds,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const reqId of reqIds) {
|
|
||||||
if (!requirementMap[reqId]) {
|
|
||||||
requirementMap[reqId] = [];
|
|
||||||
}
|
|
||||||
requirementMap[reqId].push(entry);
|
|
||||||
|
|
||||||
// Track by type
|
|
||||||
const type = reqId.substring(0, 2);
|
|
||||||
if (byType[type]) {
|
|
||||||
byType[type].add(reqId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
totalTraces++;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Skip files we can't read
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
totalFiles: files.length,
|
|
||||||
totalTraces,
|
|
||||||
requirements: requirementMap,
|
|
||||||
byType: {
|
|
||||||
UR: Array.from(byType["UR"]).sort(),
|
|
||||||
IR: Array.from(byType["IR"]).sort(),
|
|
||||||
DR: Array.from(byType["DR"]).sort(),
|
|
||||||
JA: Array.from(byType["JA"]).sort(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Coverage: how many *defined* requirements are actually traced.
|
|
||||||
//
|
|
||||||
// The denominators MUST be derived from requirements.md, never hardcoded. The
|
|
||||||
// CI gate previously divided by frozen literals (UR/39, IR/24, DR/48, JA/3,
|
|
||||||
// total 114) while the real file had grown to 211 requirements, so it reported
|
|
||||||
// 158% coverage and the 50% threshold became unreachable — the gate could not
|
|
||||||
// fail. See docs/traceability-ci.md, "Coverage Thresholds".
|
|
||||||
//
|
|
||||||
// TRACES: | DR-093
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface DefinedRequirements {
|
|
||||||
UR: number;
|
|
||||||
IR: number;
|
|
||||||
DR: number;
|
|
||||||
JA: number;
|
|
||||||
total: number;
|
|
||||||
/** Requirement IDs (UR/IR/DR/JA) — the coverage denominator. */
|
|
||||||
ids: Set<string>;
|
|
||||||
/** Test IDs (UT/IT) from §4. A separate taxonomy: never part of coverage. */
|
|
||||||
testIds: Set<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CoverageResult {
|
|
||||||
covered: number;
|
|
||||||
total: number;
|
|
||||||
percent: number;
|
|
||||||
/** Traced in code but not defined in requirements.md (typo, or deleted req). */
|
|
||||||
orphaned: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A requirement is *defined* only where its ID is the leading cell of a markdown
|
|
||||||
* table row: `| DR-001 | … |`.
|
|
||||||
*
|
|
||||||
* This deliberately ignores IDs in the "Traces To" column and in prose — a
|
|
||||||
* naive scan for /DR-\d{3}/ counts those as definitions and inflates the
|
|
||||||
* denominator. IDs are deduplicated because requirements.md lists each UR twice
|
|
||||||
* (once in §1 as a definition, again in §3's traceability matrix), which would
|
|
||||||
* otherwise double the UR count from 61 to 121.
|
|
||||||
*
|
|
||||||
* TRACES: | DR-093
|
|
||||||
*/
|
|
||||||
export function countDefinedRequirements(markdown: string): DefinedRequirements {
|
|
||||||
const ids = new Set<string>();
|
|
||||||
const testIds = new Set<string>();
|
|
||||||
const ROW_ID = /^\|\s*(UR|IR|DR|JA|UT|IT)-(\d{3})\s*\|/;
|
|
||||||
|
|
||||||
for (const line of markdown.split("\n")) {
|
|
||||||
const match = line.match(ROW_ID);
|
|
||||||
if (!match) continue;
|
|
||||||
const id = `${match[1]}-${match[2]}`;
|
|
||||||
// UT/IT rows live in §4 and are collected separately: they must not enter
|
|
||||||
// the coverage denominator, but they still need to exist for a `TRACES:`
|
|
||||||
// comment to be allowed to name them (see findDanglingIds).
|
|
||||||
if (match[1] === "UT" || match[1] === "IT") testIds.add(id);
|
|
||||||
else ids.add(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const countOf = (type: string) => [...ids].filter((id) => id.startsWith(`${type}-`)).length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
UR: countOf("UR"),
|
|
||||||
IR: countOf("IR"),
|
|
||||||
DR: countOf("DR"),
|
|
||||||
JA: countOf("JA"),
|
|
||||||
total: ids.size,
|
|
||||||
ids,
|
|
||||||
testIds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every traced ID that requirements.md defines nowhere — a typo, a rename that
|
|
||||||
* missed a call site, or a reference to a deleted requirement.
|
|
||||||
*
|
|
||||||
* This is broader than `CoverageResult.orphaned`, which only ever considers the
|
|
||||||
* four requirement types because a UT/IT entry among the orphans would corrupt
|
|
||||||
* the coverage ratio's reporting. Dangling detection has no such constraint, so
|
|
||||||
* it checks all six ID types against both defined sets. Before it existed, the
|
|
||||||
* extractor accepted any well-formed ID silently: `DR-189` and `UT-188` were
|
|
||||||
* referenced from `controlsVisibility.ts` and `VideoPlayer.svelte` for months
|
|
||||||
* without being defined anywhere, and nothing reported it.
|
|
||||||
*
|
|
||||||
* TRACES: | DR-093
|
|
||||||
*/
|
|
||||||
export function findDanglingIds(tracedIds: string[], defined: DefinedRequirements): string[] {
|
|
||||||
const KNOWN_TYPE = /^(UR|IR|DR|JA|UT|IT)-\d{3}$/;
|
|
||||||
|
|
||||||
const dangling = new Set(
|
|
||||||
tracedIds
|
|
||||||
.filter((id) => KNOWN_TYPE.test(id))
|
|
||||||
.filter((id) => !defined.ids.has(id) && !defined.testIds.has(id)),
|
|
||||||
);
|
|
||||||
|
|
||||||
return [...dangling].sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Coverage is the *intersection* of traced and defined IDs over defined IDs.
|
|
||||||
*
|
|
||||||
* Using the raw traced count as the numerator is what lets a ratio exceed 100%:
|
|
||||||
* a TRACES comment naming a requirement that no longer exists would count as
|
|
||||||
* covered. Those IDs are reported as `orphaned` so they get fixed rather than
|
|
||||||
* silently counted or silently dropped.
|
|
||||||
*
|
|
||||||
* TRACES: | DR-093
|
|
||||||
*/
|
|
||||||
export function computeCoverage(tracedIds: string[], defined: DefinedRequirements): CoverageResult {
|
|
||||||
// Only the four *requirement* types participate in coverage. UT/IT are test
|
|
||||||
// identifiers defined in §4 of requirements.md — a different taxonomy, and
|
|
||||||
// flagging them as orphans would bury real typos in ~60 lines of noise.
|
|
||||||
const isRequirement = (id: string) => /^(UR|IR|DR|JA)-\d{3}$/.test(id);
|
|
||||||
|
|
||||||
const traced = new Set(tracedIds.filter(isRequirement));
|
|
||||||
const covered = [...traced].filter((id) => defined.ids.has(id));
|
|
||||||
const orphaned = [...traced].filter((id) => !defined.ids.has(id)).sort();
|
|
||||||
|
|
||||||
return {
|
|
||||||
covered: covered.length,
|
|
||||||
total: defined.total,
|
|
||||||
percent: defined.total === 0 ? 0 : Math.round((covered.length / defined.total) * 100),
|
|
||||||
orphaned,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read requirements.md from the repo and count what it defines. */
|
|
||||||
export function readDefinedRequirements(): DefinedRequirements {
|
|
||||||
const reqPath = path.join(BASE_DIR, "docs", "requirements.md");
|
|
||||||
return countDefinedRequirements(fs.readFileSync(reqPath, "utf-8"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Path prefix that turns a repo-root-relative file path into a link target that
|
|
||||||
* resolves from `docs/traceability.md`, where this markdown is written.
|
|
||||||
*
|
|
||||||
* The generated matrix lives one directory below the repo root, so a bare
|
|
||||||
* `src-tauri/src/player/mod.rs` href resolves to `docs/src-tauri/…` and 404s —
|
|
||||||
* in the repo browser and on the published mdBook site alike. Every file link
|
|
||||||
* in the matrix was dead for this reason. The *display text* stays
|
|
||||||
* repo-root-relative (that is the path a developer types and greps for); only
|
|
||||||
* the href is rewritten.
|
|
||||||
*
|
|
||||||
* TRACES: | DR-093 | UT-202
|
|
||||||
*/
|
|
||||||
export const MATRIX_LINK_PREFIX = "../";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the ``[`path`](href#Lnn)`` link used for one trace entry in the matrix.
|
|
||||||
*
|
|
||||||
* Exported so extract-traces.test.ts can resolve a generated href against
|
|
||||||
* `docs/` and assert the target exists on disk.
|
|
||||||
*
|
|
||||||
* TRACES: | DR-093 | UT-202
|
|
||||||
*/
|
|
||||||
export function formatMatrixFileLink(file: string, line: number): string {
|
|
||||||
return `[\`${file}\`](${MATRIX_LINK_PREFIX}${file}#L${line})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateMarkdown(data: TracesData): string {
|
|
||||||
let md = `# Code Traceability Matrix
|
|
||||||
|
|
||||||
**Generated:** ${new Date(data.timestamp).toLocaleString()}
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
- **Total Files Scanned:** ${data.totalFiles}
|
|
||||||
- **Total TRACES Found:** ${data.totalTraces}
|
|
||||||
- **Requirements Covered:**
|
|
||||||
- User Requirements (UR): ${data.byType.UR.length}
|
|
||||||
- Integration Requirements (IR): ${data.byType.IR.length}
|
|
||||||
- Development Requirements (DR): ${data.byType.DR.length}
|
|
||||||
- Jellyfin API Requirements (JA): ${data.byType.JA.length}
|
|
||||||
|
|
||||||
## Requirements by Type
|
|
||||||
|
|
||||||
### User Requirements (UR)
|
|
||||||
\`\`\`
|
|
||||||
${data.byType.UR.join(", ")}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Integration Requirements (IR)
|
|
||||||
\`\`\`
|
|
||||||
${data.byType.IR.join(", ")}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Development Requirements (DR)
|
|
||||||
\`\`\`
|
|
||||||
${data.byType.DR.join(", ")}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Jellyfin API Requirements (JA)
|
|
||||||
\`\`\`
|
|
||||||
${data.byType.JA.join(", ")}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## Detailed Mapping
|
|
||||||
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Sort requirements by ID
|
|
||||||
const sortedReqs = Object.keys(data.requirements).sort((a, b) => {
|
|
||||||
const typeA = a.substring(0, 2);
|
|
||||||
const typeB = b.substring(0, 2);
|
|
||||||
const typeOrder = { UR: 0, IR: 1, DR: 2, JA: 3 };
|
|
||||||
if (typeOrder[typeA] !== typeOrder[typeB]) {
|
|
||||||
return (typeOrder[typeA] || 4) - (typeOrder[typeB] || 4);
|
|
||||||
}
|
|
||||||
return a.localeCompare(b);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const reqId of sortedReqs) {
|
|
||||||
const entries = data.requirements[reqId];
|
|
||||||
md += `### ${reqId}\n\n`;
|
|
||||||
md += `**Locations:** ${entries.length} file(s)\n\n`;
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
md += `- **File:** ${formatMatrixFileLink(entry.file, entry.line)}\n`;
|
|
||||||
md += ` - **Line:** ${entry.line}\n`;
|
|
||||||
const contextPreview = entry.context.substring(0, 70);
|
|
||||||
md += ` - **Context:** \`${contextPreview}${entry.context.length > 70 ? "..." : ""}\`\n`;
|
|
||||||
}
|
|
||||||
md += "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
return md;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateJson(data: TracesData): string {
|
|
||||||
return JSON.stringify(data, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Human-readable coverage report; exits non-zero below the threshold so this is
|
|
||||||
* runnable as a local gate (`bun run traces:coverage`), not just in CI.
|
|
||||||
*
|
|
||||||
* TRACES: | DR-093
|
|
||||||
*/
|
|
||||||
function reportCoverage(data: TracesData, minThreshold: number): number {
|
|
||||||
const defined = data.defined!;
|
|
||||||
const cov = data.coverage!;
|
|
||||||
|
|
||||||
const definedIds = readDefinedRequirements().ids;
|
|
||||||
|
|
||||||
console.log("📋 Requirement coverage (traced / defined):");
|
|
||||||
for (const type of ["UR", "IR", "DR", "JA"] as const) {
|
|
||||||
const traced = data.byType[type].filter((id) => definedIds.has(id)).length;
|
|
||||||
console.log(` ${type}: ${traced} / ${defined[type]}`);
|
|
||||||
}
|
|
||||||
console.log("");
|
|
||||||
console.log(`📈 Overall: ${cov.covered} / ${cov.total} (${cov.percent}%)`);
|
|
||||||
|
|
||||||
if (cov.orphaned.length > 0) {
|
|
||||||
console.log("");
|
|
||||||
console.log(`⚠️ Traced but not defined in requirements.md: ${cov.orphaned.join(", ")}`);
|
|
||||||
console.log(" Fix the TRACES comment or add the requirement.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.dangling && data.dangling.length > 0) {
|
|
||||||
console.log("");
|
|
||||||
console.log(
|
|
||||||
`⚠️ Dangling IDs (incl. UT/IT): ${data.dangling.join(", ")} — run \`bun run traces:validate\`.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A ratio above 100% means the computation is broken (the condition that hid
|
|
||||||
// the stale-denominator bug for so long). Fail loudly rather than report it.
|
|
||||||
if (cov.percent > 100) {
|
|
||||||
console.log("");
|
|
||||||
console.log(`❌ Coverage > 100% — the gate is miscomputing.`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cov.percent < minThreshold) {
|
|
||||||
console.log("");
|
|
||||||
console.log(`❌ Coverage (${cov.percent}%) is below minimum (${minThreshold}%)`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("");
|
|
||||||
console.log(`✅ Coverage is acceptable (${cov.percent}% >= ${minThreshold}%)`);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hard gate on dangling IDs: a `TRACES:` comment may only name an ID that
|
|
||||||
* requirements.md actually defines. Prints every offender with the files that
|
|
||||||
* reference it, so the fix is mechanical.
|
|
||||||
*
|
|
||||||
* TRACES: | DR-093
|
|
||||||
*/
|
|
||||||
function reportDangling(data: TracesData): number {
|
|
||||||
const dangling = data.dangling ?? [];
|
|
||||||
|
|
||||||
if (dangling.length === 0) {
|
|
||||||
console.log("✅ All traced IDs are defined in docs/requirements.md");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("❌ TRACES reference IDs that docs/requirements.md does not define:");
|
|
||||||
console.log("");
|
|
||||||
for (const id of dangling) {
|
|
||||||
const files = [...new Set((data.requirements[id] ?? []).map((e) => e.file))].sort();
|
|
||||||
console.log(` ${id}`);
|
|
||||||
for (const file of files) console.log(` ${file}`);
|
|
||||||
}
|
|
||||||
console.log("");
|
|
||||||
console.log("Fix each one by either:");
|
|
||||||
console.log(" • correcting the ID in the TRACES comment (typo/rename), or");
|
|
||||||
console.log(" • adding the requirement as a table row in docs/requirements.md.");
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main — guarded so this module stays importable from extract-traces.test.ts.
|
|
||||||
if (import.meta.main) {
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const format = args.includes("--format") ? args[args.indexOf("--format") + 1] : "markdown";
|
|
||||||
|
|
||||||
console.error("🔍 Extracting TRACES from codebase...");
|
|
||||||
const data = extractTraces();
|
|
||||||
|
|
||||||
const defined = readDefinedRequirements();
|
|
||||||
const allTraced = Object.keys(data.requirements);
|
|
||||||
data.defined = {
|
|
||||||
UR: defined.UR,
|
|
||||||
IR: defined.IR,
|
|
||||||
DR: defined.DR,
|
|
||||||
JA: defined.JA,
|
|
||||||
total: defined.total,
|
|
||||||
};
|
|
||||||
data.coverage = computeCoverage(allTraced, defined);
|
|
||||||
data.dangling = findDanglingIds(allTraced, defined);
|
|
||||||
|
|
||||||
if (format === "json") {
|
|
||||||
console.log(generateJson(data));
|
|
||||||
} else if (format === "coverage") {
|
|
||||||
process.exit(reportCoverage(data, MIN_COVERAGE_PERCENT));
|
|
||||||
} else if (format === "validate") {
|
|
||||||
process.exit(reportDangling(data));
|
|
||||||
} else {
|
|
||||||
console.log(generateMarkdown(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error(`\n✅ Complete! Found ${data.totalTraces} TRACES across ${data.totalFiles} files`);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
#
|
|
||||||
# Generate traceability matrix in Markdown format
|
|
||||||
#
|
|
||||||
|
|
||||||
echo "# Requirements Traceability Matrix"
|
|
||||||
echo ""
|
|
||||||
echo "**Generated**: $(date '+%Y-%m-%d %H:%M:%S')"
|
|
||||||
echo ""
|
|
||||||
echo "| Requirement | Files Implementing | Status | Notes |"
|
|
||||||
echo "|-------------|--------------------|--------|-------|"
|
|
||||||
|
|
||||||
requirements=$(grep -E "^\| (UR|IR|DR|JA)-[0-9]+" README.md | sed -E 's/^\| ([A-Z]+-[0-9]+).*/\1/' | sort -u)
|
|
||||||
|
|
||||||
for req in $requirements; do
|
|
||||||
files=$(grep -rl "@req: $req" src-tauri/ src/ 2>/dev/null | \
|
|
||||||
sed 's|src-tauri/src/||; s|src/||' | \
|
|
||||||
paste -sd, -)
|
|
||||||
|
|
||||||
partial_files=$(grep -rl "@req-partial: $req" src-tauri/ src/ 2>/dev/null | wc -l)
|
|
||||||
planned=$(grep -rl "@req-planned: $req" src-tauri/ src/ 2>/dev/null | wc -l)
|
|
||||||
|
|
||||||
if [ -n "$files" ]; then
|
|
||||||
status="✅ Done"
|
|
||||||
notes=""
|
|
||||||
elif [ "$partial_files" -gt 0 ]; then
|
|
||||||
status="🔶 Partial"
|
|
||||||
notes="Platform-specific"
|
|
||||||
elif [ "$planned" -gt 0 ]; then
|
|
||||||
status="📋 Planned"
|
|
||||||
notes="Not implemented"
|
|
||||||
else
|
|
||||||
status="❌ Missing"
|
|
||||||
notes="No implementation"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "| $req | ${files:-N/A} | $status | $notes |"
|
|
||||||
done
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# JellyTau pre-commit hook — the fast half of CLAUDE.md's "Before Committing"
|
|
||||||
# list, enforced instead of remembered.
|
|
||||||
#
|
|
||||||
# TRACES: | DR-207
|
|
||||||
#
|
|
||||||
# Install with: bun run hooks:install (sets core.hooksPath=scripts/hooks)
|
|
||||||
# Skip once with: git commit --no-verify
|
|
||||||
#
|
|
||||||
# What runs here is deliberately limited to gates that finish in seconds:
|
|
||||||
#
|
|
||||||
# bun run check svelte-check (types)
|
|
||||||
# bun run test vitest, single pass
|
|
||||||
# scripts/check-frontend-boundary.sh domain-taxonomy tripwire (DR-094)
|
|
||||||
# bun run format:check prettier
|
|
||||||
# bun run lint --max-warnings=N eslint, warning-count ratchet
|
|
||||||
# cargo fmt --all -- --check only when src-tauri/ is staged
|
|
||||||
#
|
|
||||||
# NOT here, on purpose: `cargo clippy` and `cargo test`. Both take minutes on a
|
|
||||||
# cold target dir, which turns every commit into a coffee break and trains
|
|
||||||
# people to reach for --no-verify. CI (.gitea/workflows/build-and-test.yml) is
|
|
||||||
# where those run; `bun run test:all` is the local equivalent.
|
|
||||||
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
# Merge and rebase commits carry someone else's changes, and conflict resolution
|
|
||||||
# is exactly when a slow gate is least welcome. Let them through — CI still
|
|
||||||
# gates the merge result.
|
|
||||||
GIT_DIR_PATH="$(git rev-parse --git-dir 2>/dev/null)" || exit 0
|
|
||||||
if [ -e "$GIT_DIR_PATH/MERGE_HEAD" ] ||
|
|
||||||
[ -d "$GIT_DIR_PATH/rebase-merge" ] ||
|
|
||||||
[ -d "$GIT_DIR_PATH/rebase-apply" ] ||
|
|
||||||
[ -e "$GIT_DIR_PATH/CHERRY_PICK_HEAD" ]; then
|
|
||||||
echo "pre-commit: merge/rebase in progress — skipping checks (CI still gates the result)."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Nothing staged (e.g. `git commit --amend` that only edits the message): nothing
|
|
||||||
# to check.
|
|
||||||
STAGED="$(git diff --cached --name-only --diff-filter=ACMR)"
|
|
||||||
if [ -z "$STAGED" ]; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
||||||
cd "$REPO_ROOT" || exit 1
|
|
||||||
|
|
||||||
FAILED=0
|
|
||||||
|
|
||||||
run_gate() {
|
|
||||||
label="$1"
|
|
||||||
shift
|
|
||||||
echo ""
|
|
||||||
echo "🔎 pre-commit: $label"
|
|
||||||
if ! "$@"; then
|
|
||||||
echo "❌ pre-commit: $label failed"
|
|
||||||
FAILED=1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
run_gate "svelte-check (bun run check)" bun run check
|
|
||||||
run_gate "frontend tests (bun run test)" bun run test
|
|
||||||
run_gate "frontend/backend boundary" bash scripts/check-frontend-boundary.sh
|
|
||||||
run_gate "formatting (bun run format:check)" bun run format:check
|
|
||||||
# Same ratchet as the CI step in build-and-test.yml — keep the two numbers equal,
|
|
||||||
# or a commit passes here and fails there.
|
|
||||||
run_gate "lint (bun run lint)" bun run lint --max-warnings=159
|
|
||||||
|
|
||||||
# rustfmt only matters when Rust actually changed, and `cargo fmt --check` is
|
|
||||||
# cheap (no compilation) whenever it does.
|
|
||||||
if printf '%s\n' "$STAGED" | grep -q '^src-tauri/'; then
|
|
||||||
if command -v cargo >/dev/null 2>&1; then
|
|
||||||
echo ""
|
|
||||||
echo "🔎 pre-commit: rustfmt (src-tauri/ is staged)"
|
|
||||||
if ! (cd src-tauri && cargo fmt --all -- --check); then
|
|
||||||
echo "❌ pre-commit: cargo fmt --all -- --check failed"
|
|
||||||
echo " fix with: cd src-tauri && cargo fmt"
|
|
||||||
FAILED=1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "⚠️ pre-commit: src-tauri/ staged but cargo is not on PATH — skipping rustfmt."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$FAILED" -ne 0 ]; then
|
|
||||||
echo ""
|
|
||||||
echo "🛑 pre-commit checks failed. Fix them, or bypass deliberately with:"
|
|
||||||
echo " git commit --no-verify"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "✅ pre-commit checks passed."
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Point git at the repo's tracked hooks directory.
|
|
||||||
#
|
|
||||||
# TRACES: | DR-207
|
|
||||||
#
|
|
||||||
# bun run hooks:install # or: ./scripts/install-hooks.sh
|
|
||||||
#
|
|
||||||
# `core.hooksPath` is used rather than copying files into .git/hooks so the
|
|
||||||
# hooks stay version-controlled: an update to scripts/hooks/pre-commit reaches
|
|
||||||
# everyone on their next pull instead of needing a re-install.
|
|
||||||
#
|
|
||||||
# The setting is local to this clone (git config, not committed). To undo:
|
|
||||||
# git config --unset core.hooksPath
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
||||||
cd "$REPO_ROOT"
|
|
||||||
|
|
||||||
HOOKS_DIR="scripts/hooks"
|
|
||||||
|
|
||||||
if [ ! -d "$HOOKS_DIR" ]; then
|
|
||||||
echo "❌ $HOOKS_DIR does not exist — are you in the JellyTau repo?" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Git refuses to run a hook that is not executable, and the bit is easy to lose
|
|
||||||
# on a fresh checkout on some filesystems.
|
|
||||||
chmod +x "$HOOKS_DIR"/* 2>/dev/null || true
|
|
||||||
|
|
||||||
git config core.hooksPath "$HOOKS_DIR"
|
|
||||||
|
|
||||||
echo "✅ core.hooksPath = $(git config core.hooksPath)"
|
|
||||||
echo ""
|
|
||||||
echo "Installed hooks:"
|
|
||||||
for hook in "$HOOKS_DIR"/*; do
|
|
||||||
[ -f "$hook" ] || continue
|
|
||||||
echo " - $(basename "$hook")"
|
|
||||||
done
|
|
||||||
echo ""
|
|
||||||
echo "pre-commit runs: bun run check, bun run test, check-frontend-boundary.sh,"
|
|
||||||
echo "and cargo fmt --check when src-tauri/ is staged."
|
|
||||||
echo "Bypass a single commit with: git commit --no-verify"
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# View Android logcat output filtered for the app.
|
|
||||||
#
|
|
||||||
# Usage: ./scripts/logcat.sh [debug|release] (default: debug)
|
|
||||||
#
|
|
||||||
# The debug build has applicationIdSuffix ".debug" so it can be installed
|
|
||||||
# alongside a release build; pick the package to follow accordingly.
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
BUILD_TYPE="${1:-debug}"
|
|
||||||
|
|
||||||
if [ "$BUILD_TYPE" = "release" ]; then
|
|
||||||
APP_PACKAGE="com.dtourolle.jellytau"
|
|
||||||
else
|
|
||||||
APP_PACKAGE="com.dtourolle.jellytau.debug"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "📱 Showing logcat for $APP_PACKAGE"
|
|
||||||
echo "Press Ctrl+C to stop"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Prefer PID-scoped output when the app is running — it drops the noise that a
|
|
||||||
# text grep can't. Fall back to the old keyword filter when it isn't (so you can
|
|
||||||
# start the script first and then launch the app).
|
|
||||||
PID="$(adb shell pidof "$APP_PACKAGE" 2>/dev/null | tr -d '\r\n' | awk '{print $1}')"
|
|
||||||
|
|
||||||
if [ -n "$PID" ]; then
|
|
||||||
echo " (attached to pid $PID)"
|
|
||||||
adb logcat --pid="$PID"
|
|
||||||
else
|
|
||||||
echo " (app not running — falling back to keyword filter)"
|
|
||||||
adb logcat | grep -i "$APP_PACKAGE\|jellytau\|tauri\|rust"
|
|
||||||
fi
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user