Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bce81a800 | ||
|
|
65d3d912f7 | ||
|
|
192a8b3c67 | ||
|
|
747ec0161c | ||
|
|
bc92eb4dea | ||
|
|
c72ca86865 | ||
|
|
f9e1a8e69a | ||
|
|
d4f80a4afa | ||
|
|
7b738002a0 | ||
|
|
c41b8ec896 | ||
|
|
dea78b89b9 | ||
|
|
368935e6f4 | ||
|
|
c44070e720 | ||
|
|
8ecf74a2af | ||
|
|
5fb9c1ff3b | ||
|
|
b5a3a3b427 | ||
|
|
10d77f1380 | ||
|
|
03c0b5cd17 | ||
|
|
27a995f877 | ||
|
|
f902caa07f | ||
|
|
fb3d0014ef | ||
|
|
da762da55d | ||
|
|
8a04a6fad0 | ||
|
|
c0399e4ebd |
@@ -0,0 +1,348 @@
|
||||
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
|
||||
+101
@@ -9,6 +9,107 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
||||
For how long each fixed defect had been shipping before it was found, see
|
||||
[docs/defect-windows.md](docs/defect-windows.md).
|
||||
|
||||
## v0.11.6
|
||||
|
||||
Found by an audit of the stack's most fragile seams rather than by hitting them,
|
||||
so most of these are faults that had not yet been reported — several could only
|
||||
be reached on a bad day, and the worst of them only once.
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- **An interrupted update can no longer stop the app from ever opening again.**
|
||||
Changes to the local database were applied one statement at a time with no way
|
||||
to undo a half-finished one. If an update was interrupted partway — a full
|
||||
disk, the phone reclaiming memory, the app being killed mid-launch — the
|
||||
earlier statements stuck while nothing recorded that the change had happened.
|
||||
On the next launch it started again from the beginning, immediately hit the
|
||||
part that was already done, and gave up; and since the app treats a database
|
||||
it cannot prepare as fatal, it stopped opening at all, on every launch, with
|
||||
the only way out being to clear its data and lose downloads and sign-ins. Each
|
||||
change is now all-or-nothing, so an interrupted one leaves no trace and the
|
||||
next launch simply tries again. (UR-002 → DR-012)
|
||||
|
||||
- **The app no longer vanishes without trace when the player hits trouble.**
|
||||
The parts of the Android player that report back into the app — position,
|
||||
state changes, errors, the end of a track — had no protection around them, and
|
||||
a failure inside one killed the whole app instantly: no error, no message, not
|
||||
even a crash report worth sending. One such failure was reachable in ordinary
|
||||
use, on the position report that fires four times a second: under memory
|
||||
pressure the app could fail to build the small worker it needs to send that
|
||||
report, and that alone was enough to take everything down. A dropped position
|
||||
report is now just a dropped position report. (UR-005 → DR-052)
|
||||
|
||||
- **One internal failure no longer disables the whole app until it is restarted.**
|
||||
Every part of the app that reads or writes local data shares a single gate to
|
||||
it. If anything failed while holding that gate, the gate stayed jammed: from
|
||||
then on every library page, download, setting and sign-in returned an error for
|
||||
the rest of the session, and only quitting and reopening cleared it. The gate
|
||||
now recovers instead of jamming. (UR-002 → DR-012)
|
||||
|
||||
- **Browsing offline no longer reports a network error over content already on
|
||||
the device.** A read of local data was given a tenth of a second to answer and
|
||||
otherwise abandoned and treated as "nothing stored". That is easily exceeded
|
||||
on phone storage whenever something else is writing — a sync catching up, a
|
||||
batch of artwork being saved — and offline, where there is no server to fall
|
||||
back to, the result was a network error shown over a library that was sitting
|
||||
on disk. Worse, the abandoned read kept running and kept the storage busy,
|
||||
making the next one slower still. A slow read is now waited for rather than
|
||||
thrown away, and a fast one still answers immediately as before. (UR-002 →
|
||||
DR-013)
|
||||
|
||||
- **A download that arrived empty is no longer presented as ready to play.** If
|
||||
the server answered a download with nothing at all — an error page, a
|
||||
conversion that produced no output — the empty file was moved into place and
|
||||
the item was marked available offline. Opening it then hung: the app's own
|
||||
media server promised one byte of it and sent none, so the player waited
|
||||
forever with nothing on screen to say why. An empty download is now treated as
|
||||
the failure it is, keeping the partial file so it can resume, and a request for
|
||||
an empty file gets an honest refusal instead of a promise. (UR-019, UR-071 →
|
||||
DR-168, DR-137)
|
||||
|
||||
- **Renaming your computer no longer signs you out.** On systems without a
|
||||
password manager, sign-in tokens are kept in a file whose key was rebuilt from
|
||||
the machine's name and the current username each time the app started. Rename
|
||||
the machine, or launch it from somewhere the username is not set, and the key
|
||||
came out different, the file could no longer be read, and the app treated that
|
||||
as never having been signed in — with nothing shown to explain it. The key is
|
||||
now made once, kept, and unaffected by what the machine is called. Existing
|
||||
saved sign-ins are carried over automatically. This file has never been a
|
||||
substitute for a real password manager, and the app now says so plainly rather
|
||||
than implying otherwise. (UR-012 → IR-014)
|
||||
|
||||
- **The player's internal locking is now checked rather than merely careful.**
|
||||
The playback controller coordinates seventeen separate pieces of shared state
|
||||
across the audio engine, the lock screen, timers and every screen in the app.
|
||||
Nothing stopped two of them being taken in opposite orders by different parts
|
||||
of the code, which freezes playback outright with no error anywhere — a fault
|
||||
this part of the app has produced before. The correct order is now written down
|
||||
and enforced automatically, so a future change cannot quietly reintroduce it.
|
||||
No such fault existed; this keeps it that way. (UR-005 → DR-052)
|
||||
|
||||
## v0.11.5
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- **A video in a picture-in-picture window stays a video.** Watching in a PiP
|
||||
window would sometimes drop to audio only, and the audio would pick up from
|
||||
wherever the video had been when the window opened — while the picture itself
|
||||
had carried on well past that. Two faults, both needed to produce it. The
|
||||
player's idea of where it is in the video is kept by a loop that only runs
|
||||
while the app is drawing to the screen, and behind a PiP window it is not:
|
||||
the position quietly stopped advancing the moment the window opened, and the
|
||||
one other source that could have kept it current had been written to switch
|
||||
itself off during playback. Separately, PiP and the "keep the audio playing in
|
||||
the background" toggle are meant to be alternatives, but only the toggle
|
||||
enforced that — the PiP button could still be pressed with both armed, and the
|
||||
single moment-in-time check meant to tell them apart is not always right about
|
||||
whether a window is open. Opening PiP now turns background audio off, the app
|
||||
trusts its own record of the window as well as the system's, and the position
|
||||
keeps advancing whether or not anything is being drawn. The frozen position
|
||||
also affected the seek bar, resume points and watch progress reported to the
|
||||
server for as long as a PiP window was open. (UR-004, UR-040, UR-041 →
|
||||
DR-265, DR-266)
|
||||
|
||||
## v0.11.4
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
@@ -17,11 +17,17 @@ row can be re-checked or disputed:
|
||||
|
||||
## Present since the first release
|
||||
|
||||
Fifteen defects date to the initial proof of concept (v0.0.1, 2026-06-23) and
|
||||
Sixteen defects date to the initial proof of concept (v0.0.1, 2026-06-23) and
|
||||
shipped for between two weeks and two 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 |
|
||||
@@ -40,6 +46,7 @@ went unexercised until a later feature leaned on them.
|
||||
| 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 |
|
||||
|
||||
### Why they took so long to surface
|
||||
|
||||
@@ -86,6 +93,7 @@ silently correct an out-of-range index — which is exactly why it was reported
|
||||
| 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**:
|
||||
|
||||
+31
-4
@@ -91,6 +91,9 @@ For a narrative overview of the system design, see
|
||||
| UR-079 | The app decides *what stream to play* and says so. Playing a video used to mean asking the server to re-encode it, always — a decision made nowhere, written down nowhere, and re-derived downstream by whoever needed it: the player worked out whether it had been handed a playlist by looking for `.m3u8` in the URL. So a viewer paid for a transcode of a file their device could have played untouched, and the app could not tell them which it was. Now one negotiation produces one self-describing answer — direct play, remux, or transcode; over a playlist, a plain HTTP file, or a local one — and every renderer consumes that same answer instead of guessing from a string. On Android, where the player decodes almost everything the library holds, this stops around 85% of plays from starting a transcode nobody needed | Medium | Done |
|
||||
| UR-080 | Video on the desktop plays as itself. The picture was drawn by a webview `<video>` element, which decodes little beyond h264 — so the app told the server it could accept only h264, and the server re-encoded almost everything before sending it. That was never a statement about the machine: the same machine already runs mpv for audio, which decodes essentially the whole library. Measured against a real library, 93% of desktop playback was a transcode nobody needed, against 15% on Android where a real decoder does the work. mpv now draws the picture, the app claims what it can genuinely decode, and video is sent as it was stored wherever that is possible — sparing the server the work, the network the bitrate, and the picture a generation of re-encoding | Medium | Proposed |
|
||||
| UR-081 | Playback behaves the same whichever engine renders it | High | In Progress |
|
||||
| UR-082 | A shared device holds more than one account from the same server, and changing who is using it takes a couple of taps rather than a password. Switching away leaves the account it left able to come straight back, and each account sees only its own library, its own progress and its own downloads — including offline, where the server is not there to filter | Medium | Proposed |
|
||||
| UR-083 | An account can be locked behind a short numeric code, so that on a family device the accounts that need protecting are protected and the ones that do not are one tap away. The code gates switching to that account, not what the account may watch. Repeated wrong guesses stop being answered | Medium | Proposed |
|
||||
| UR-084 | Forgetting the code is not a lockout: the account's ordinary password gets in, and a new code can be set from there | Medium | Proposed |
|
||||
| UR-074 | Video streaming can be held to a **bandwidth budget the viewer sets**, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once — from the source's own bitrate down to a rung that still plays on a poor connection — governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that default | Medium | Done |
|
||||
|
||||
---
|
||||
@@ -136,6 +139,7 @@ External system integrations and platform-specific implementations.
|
||||
| IR-031 | Android `WindowInsets` bridge: an `OnApplyWindowInsetsListener` on the decor view reports `systemBars() | displayCutout()` in CSS pixels, pushed into the WebView as `jt-inset` CSS custom properties plus a `jellytau-insets-changed` event, and pullable via the `AndroidInsets` JS bridge | Platform | UR-066 | Done (pending device verification) |
|
||||
| IR-032 | Whole-file background download of the item being played, reusing the existing resumable download worker and the Range-capable `/Videos/{id}/stream.mp4` endpoint; plus per-platform read-through caching hooks (ExoPlayer `CacheDataSource`, mpv `stream-record`) for direct-play sessions only | Storage | UR-071 | Proposed |
|
||||
| IR-033 | libmpv render-API integration for video: `vo=libmpv` driving an OpenGL FBO bound by the host toolkit, with GL entry points resolved through libepoxy. Note that libepoxy exports them as *data* symbols — there is no `glFoo` function, only an `epoxy_glFoo` variable holding a lazily-resolving pointer — so `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 resolves this correctly but is unusable, its `gl_generator` dependency pulling a yanked `xml-rs` | Playback | UR-080 | Proposed |
|
||||
| IR-034 | One downloaded file serves every account that asked for it: the download row owns the bytes, a per-user grant owns the claim, and the file is unlinked only when the last grant goes. The on-disk layout is already content-derived rather than user-derived, so this formalises what the paths already imply and stops two accounts clobbering one file | Storage | UR-082 | Proposed |
|
||||
|
||||
> **Where a UR is met by a different mechanism than its IR anticipated.** Several
|
||||
> integration requirements were written when libmpv was expected to be the single
|
||||
@@ -458,6 +462,20 @@ Internal architecture, components, and application logic.
|
||||
| DR-262 | The A-Z jump strip is bounded by the **scroller it lives in**, not by the viewport minus a guess at the bottom bars. `AlphabetScrollBar` sized itself as `window.innerHeight` minus a hardcoded `bottomGap` — 5rem, 7rem or 11rem, picked by platform and whether the mini player was showing — which dates from when the mini player and bottom nav were `position: fixed` overlays. They have been in-flow flex siblings below the scroller since BottomUi (DR-009), so the scroller's own bottom edge *is* the top of the mini player and can simply be measured. The guess was short on every device with a navigation or gesture bar, because `--safe-bottom` is padded *inside* BottomUi (DR-112) and no guess knew about it: the strip overran the scrollport by ~45px with the nav alone, ~18px with the mini player and ~50px in remote mode, burying one to three letters where they could not be tapped. The ancestor is resolved by computed `overflow-y` rather than `closest("main")`, since the root shell scrolls in a plain `<div>` and a miss silently fell back to the viewport — reinstating the bug on any route outside `/library`. Observing the scroller for resize is also what makes the mini player appearing re-measure, so the component no longer subscribes to player or platform stores at all | UI | UR-007 | Done |
|
||||
| DR-263 | Autoplay crosses the **season boundary**. `fetch_next_episode_for_item` listed the episodes of the current season and stopped dead at the last one, so the end of a season produced `AutoplayDecision::Stop`. On the Android background-audio handoff (UR-040) that is felt as playback simply pausing mid-binge with the screen locked and no UI to un-pause it — the same end that mid-season advances through in the backend. The lookup now walks the series' seasons, sorted client-side by index number because the offline repository ignores `sort_by`, and takes the first episode of the next season that has any, skipping empty ones. Specials are never rolled *into*: Jellyfin numbers them 0 so they sort ahead of season 1, but a server that leaves the index unset sorts them last, exactly where the walk would land. The lookup sits below the sleep-timer gate in `on_playback_ended`, so a timer set to end-of-episode or a remaining-episode count still stops at the boundary rather than being carried past it | Player | UR-023, UR-040 | Done |
|
||||
| DR-264 | The episode a viewer *just finished* is no longer offered as the one they are up to. Nothing records completion locally: the stop report writes a position through `storage_update_playback_progress` (which never sets `is_played`), and the cache mirror carried the server's flag not at all — so on a cache hit every episode read back as unwatched. Leaving the player with Back reloads the series page within a second of the stop report, inside the window where Jellyfin's Next Up still names the episode that just ended, and `pick_current_episode` handed it straight back: the season view kept the yellow ring and the "Up next" badge on the episode the viewer had just watched, and scrolled to it. Two halves. (a) `is_finished` — the played flag **or** a position at or past `MAX_PROGRESS_FRACTION` of the runtime, the same 95% threshold that already disqualifies an episode from counting as in-progress — replaces the bare `is_played` in the furthest-watched scan and the first-unwatched fallback, and screens the Next Up candidate: the server is one stop-report behind for a moment, the local position is not. (b) `OfflineRepository::mirror_user_data` carries `is_played` alongside the favourite flag and the position, under the same `pending_sync = 0` conflict rule, so watched state survives a cache write instead of being dropped — that flag was previously written by nothing but an explicit local toggle | Repository | UR-062 | Done |
|
||||
| DR-265 | The player's position variable keeps advancing behind a picture-in-picture window. `VideoPlayer` tracks the absolute position in its own `currentTime` rather than reading `videoElement.currentTime` at the point of use — transcoded HLS resets the element to 0 on every segment rebuild, so only the running total is meaningful — and that variable had exactly one writer while playing: a `requestAnimationFrame` loop. RAF is driven by the document being rendered, and an Android activity behind a PiP window is paused, so the loop stops while the element plays on. The `timeupdate` handler that would have covered the gap was written as a fallback "for when RAF isn't running" and gated itself on `!isPlaying`, switching itself off at precisely the moment it was the only source left. `currentTime` therefore froze at the instant PiP was entered, and every consumer froze with it: the seek bar, the ten-second progress reports, the position mirrored into Rust through `html5Adapter`, and — the reported symptom — the background-audio handoff, which resumed the audio-only stream at the PiP-entry position while the picture carried on where it really was. The gate is now `shouldApplyTimeUpdate` and turns only on the things that genuinely own the position instead: an in-flight seek, a seek-bar drag, and an element whose `readyState` is below `HAVE_CURRENT_DATA` (which reads 0 and would rewind). Both writers producing the same derived value costs nothing — the element is the authority either way | Player | UR-004, UR-041 | Done |
|
||||
| DR-266 | PiP and the background-audio handoff can no longer be armed at once, and neither can a single stale boolean end the picture. They are alternatives — one keeps the video on screen, the other throws it away — but exclusivity was enforced from one side only: arming the toggle called `setAutoEnterEnabled(false)`, while the PiP *button* stayed ungated and still worked, so pressing it left both live. What then decided between them was `isInPictureInPictureMode`, sampled once inside `MainActivity.onStop()` and passed to `background_action`. That sample is not reliable: there are orderings — the keyguard dismissing the window, the window being stashed, OEM variance in when `onPictureInPictureModeChanged(false)` lands relative to `onStop` — where the activity is stopped with a PiP window still on screen and the flag reads false. Backgrounding then meant "the app is gone" and handed a video the user was watching in the window off to audio-only. Two halves. (a) `enteringPictureInPicture` disarms background audio, because pressing PiP is an unambiguous request to keep the picture; both directions now go through one `BackgroundBehaviour` pair rather than two ad-hoc call sites. (b) `inPictureInPicture` accepts either witness — the native sample or the frontend's own latch over `jellytau-pip-entered`/`jellytau-pip-exited`. The latch cannot report a window that has closed, because both events reach the WebView through the same message queue in dispatch order, so a genuine exit is always known before the background signal that follows it. The decision itself stays in Rust; the frontend only supplies a fact it can establish more reliably than the activity can | Player | UR-040, UR-041 | Done |
|
||||
| DR-267 | `profiles_*` commands expose the accounts already stored in `users` — list, add, remove, and a startup target that decides between resuming the last account and showing the picker. `storage_get_users` and `storage_set_active_user` have existed and gone uncalled since the schema was written; what was missing was never the storage but the decision of who may switch to what, which is domain logic and stays in Rust. Adding an account authenticates against the *current* server and takes no URL, which is how the same-server constraint is enforced rather than by omitting a form field | Auth | UR-082 | Proposed |
|
||||
| DR-268 | A profile's PIN is an Argon2id hash in `user_pins` with the failure count and lockout deadline beside it, both read and written only in Rust. The PIN deliberately does **not** encrypt the access token: wrapping it would leave a locked profile unable to resume its own downloads, drain its own `sync_queue` or poll its own sessions until someone typed the code, which on a device that reboots nightly costs more than it defends against a four-digit secret. The gate is against a member of the household, and the security doc says so rather than implying at-rest protection it does not provide | Auth | UR-083 | Proposed |
|
||||
| DR-269 | A forgotten PIN falls through to the ordinary password login against the same server, after which a new PIN can be set. There is no reset token, no recovery secret and no administrator approval path — the account's own password is already the authority, and inventing a second one would be a weaker credential guarding the same thing | Auth | UR-084 | Proposed |
|
||||
| DR-270 | Switching profiles is its own operation, not a logout. `auth_logout` calls Jellyfin's logout endpoint and invalidates the token server-side, which is exactly the behaviour a switch must not have. The switch runs as a state machine over a `ProfileSession` — stop playback and drop the queue, park the outgoing user's sync queue, stop the poller, destroy the repository, flip `is_active`, rebuild — so the teardown *ordering* can be unit-tested with no player and no server. The queue cannot outlive its owner: a straggler reporting after the flip would attribute one account's viewing to another, which is silent and unrecoverable | Auth | UR-082 | Proposed |
|
||||
| DR-271 | Cache visibility is a byproduct of the write path rather than a maintained index. `save_to_cache` is the single choke point through which every cached item passes and it already holds the `user_id` it fetched for, so it stamps `user_item_visibility` in the same transaction; reads join through it. The stamp cannot disagree with the server because it *is* the record of what the server returned, and there is nothing to reconcile. It does not shrink when permissions tighten — that drift is bounded by re-deriving from `/UserViews` on unlock while online, and is stale-permissive offline by design | Repository | UR-082 | Proposed |
|
||||
| DR-272 | `offline_is_available` answers per user instead of per item. It counted completed `downloads` rows for an `item_id` with no user predicate, so every profile on the device saw every other profile's downloads as its own — the same class of leak as the shared metadata cache, in the one place where the server is not present to filter | Storage | UR-082 | Proposed |
|
||||
| DR-273 | Each profile derives its own `DeviceId` as `uuid5(device_uuid, user_id)` rather than sharing the installation's. Jellyfin identifies a *session* by device, so a shared id makes a family look like one device that keeps changing user — playback history, the Devices dashboard and the remote-control target all collapse together | Auth | UR-082 | Proposed |
|
||||
| DR-274 | Startup shows the picker only when the last-used profile has a PIN, or when more than one profile exists and the setting asks for it; otherwise it resumes exactly as before. The feature is invisible to a single-account install, which is what makes it safe to ship without a migration anyone has to think about | Auth | UR-082 | Proposed |
|
||||
| DR-275 | Idle re-lock separates "the UI is locked" from "who is the active profile", so audio keeps playing and keeps reporting as the account that started it while the screen is locked. Lockscreen transport controls keep working untouched, because nothing on a lockscreen browses or starts new content — the locked UI refuses only what reaches past the current queue. The timer starts when playback stops rather than when the UI goes quiet, and unlocking to a *different* profile stops playback. It lives in Rust beside the player state machine: it needs authoritative playback state, and a frontend timer dies with the WebView on Android | Player | UR-083 | Proposed |
|
||||
| DR-276 | The picker and PIN pad render an opaque `unlock_method` and an `UnlockOutcome` union the backend returns; the frontend never compares a PIN, counts an attempt, or infers that an account without a PIN is a child's. "Child account" is not modelled at all — a child profile is simply one with no PIN — so no role taxonomy is invented on either side of a boundary that has leaked taxonomy before | Frontend | UR-082, UR-083 | Proposed |
|
||||
| DR-277 | A library listing is scoped to that library. The cached-browse query matched a library parent with an `EXISTS` that never referenced the item — it asked only whether a library with the requested id existed — so the clause was true for every cached row on the server. Music, Movies and TV concealed it because their landing pages pass `include_item_types`, which narrowed the result; the generic library page passes none, so opening Books, Photos, Collections or a mixed library served whatever happened to be cached. The stored `library_id` now decides wherever the cache kept one, because that is the server's own answer and the only thing able to scope a library whose type has no mapping or none at all; the `collection_type` ↔ `item_type` taxonomy is the fallback for rows written before it was stored, and a library with neither matches nothing and falls through to the server. The taxonomy itself is now a single macro shared with the downloaded listing, which had the identical defect fixed in isolation (DR-167) while this path kept it | Repository | UR-007 | Done |
|
||||
| DR-278 | Cached items record the library they came from. `save_to_cache` bound `library_id` NULL on every row it wrote, so the only association available was the `collection_type` ↔ `item_type` taxonomy — which cannot distinguish two libraries of the *same* type (a server with "TV" and "Shows" served both the same contents) and says nothing about a library whose type it does not map. The write path is the single choke point every cached row passes through and it already knows the parent being browsed, so it resolves the owning library once per call: the parent itself when it is a library, otherwise the library its parent item was already filed under, which propagates the association down a hierarchy as it is browsed. Synthetic parents such as `favorites` match neither and stay NULL, since they are not a library and span several. Existing rows cannot be repaired locally — the association was never stored — so migration 025 clears `synced_at` to force a re-fetch, the same move MIGRATION_018 made for `is_folder`; the taxonomy fallback stays for one release while caches refill | Repository | UR-007 | Done |
|
||||
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
|
||||
|
||||
---
|
||||
@@ -471,10 +489,10 @@ Internal architecture, components, and application logic.
|
||||
| UR-001 | IR-001, IR-002 | - |
|
||||
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
|
||||
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010, DR-182, DR-183, DR-184, DR-185, DR-186, DR-187, DR-188, DR-190, DR-191, DR-192, DR-193, DR-194, DR-195, DR-196 |
|
||||
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203 |
|
||||
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203, DR-265 |
|
||||
| UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195 |
|
||||
| UR-006 | IR-005, IR-006, IR-007, IR-008 | DR-200, DR-201 |
|
||||
| UR-007 | IR-010 | DR-007, DR-008, DR-016, DR-257, DR-262 |
|
||||
| UR-007 | IR-010 | DR-007, DR-008, DR-016, DR-257, DR-262, DR-277, DR-278 |
|
||||
| UR-008 | IR-010 | DR-007, DR-011 |
|
||||
| UR-009 | IR-009, IR-010, IR-011 | - |
|
||||
| UR-010 | IR-012, IR-021 | DR-037, DR-059 |
|
||||
@@ -507,8 +525,8 @@ Internal architecture, components, and application logic.
|
||||
| UR-037 | IR-010 | DR-042 |
|
||||
| UR-038 | IR-010 | DR-043 |
|
||||
| UR-039 | - | DR-045, DR-046 |
|
||||
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203, DR-263 |
|
||||
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188 |
|
||||
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203, DR-263, DR-266 |
|
||||
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188, DR-265, DR-266 |
|
||||
| UR-042 | IR-009, IR-014 | DR-054 |
|
||||
| UR-043 | IR-027 | DR-055 |
|
||||
| UR-044 | - | DR-056 |
|
||||
@@ -547,6 +565,9 @@ Internal architecture, components, and application logic.
|
||||
| UR-078 | - | DR-218 |
|
||||
| UR-079 | - | DR-225, DR-226, DR-227, DR-228, DR-229, DR-230 |
|
||||
| UR-080 | IR-033 | DR-231, DR-232, DR-233, DR-234, DR-235, DR-236, DR-237 |
|
||||
| UR-082 | IR-034 | DR-267, DR-270, DR-271, DR-272, DR-273, DR-274, DR-276 |
|
||||
| UR-083 | - | DR-268, DR-275, DR-276 |
|
||||
| UR-084 | - | DR-269 |
|
||||
|
||||
---
|
||||
|
||||
@@ -795,6 +816,12 @@ Internal architecture, components, and application logic.
|
||||
| UT-243 | A track that names no album has no container to collapse into and stays a track, the same way a movie does | JA-016 | Done |
|
||||
| UT-244 | Recently Added over-fetches before collapsing, so folding one 14-track import together does not leave the row nearly empty | JA-016 | Done |
|
||||
|
||||
| UT-245 | A `timeupdate` is applied while the video is playing — the case that froze the position behind a PiP window — and still yields to an in-flight seek, a seek-bar drag, and an element with no current data | DR-265 | Done |
|
||||
| UT-246 | Opening a PiP window disarms background audio, and a background signal arriving with the native PiP flag false is still treated as PiP while the frontend's latch says the window is open — without resurrecting one it has already seen close | DR-266 | Done |
|
||||
| UT-247 | A library whose `collection_type` has no mapping — Books, Photos, a mixed library — does not return the server's films, albums and shows from cache | DR-277 | Done |
|
||||
| UT-248 | Narrowing the library clause does not starve the libraries that do have landing pages: music, movies and TV each still list their own media and none of the others | DR-277 | Done |
|
||||
| UT-249 | Opening an individual collection still lists its own children: a BoxSet's members are matched by the stored `parent_id`, not by the library clause, so narrowing that clause did not empty collections | DR-277 | Done |
|
||||
| UT-250 | Two libraries of the same collection type are not interchangeable: seeded through the real cache write path, a "TV" and a "Shows" library each list their own series and not the other's | DR-278 | Done |
|
||||
### Integration Tests
|
||||
|
||||
| Test ID | Test Description | Traces To | Status |
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
# 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
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.11.4",
|
||||
"version": "0.11.6",
|
||||
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
|
||||
"author": "Duncan Tourolle <duncan@tourolle.paris>",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
# tarball/VCS URL and drop the local-copy prepare() step.
|
||||
|
||||
pkgname=jellytau
|
||||
pkgver=0.11.4
|
||||
pkgver=0.11.5
|
||||
pkgrel=1
|
||||
pkgdesc="A cross-platform Jellyfin client"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -7,7 +7,7 @@ echo "======================================"
|
||||
# Setup environment
|
||||
echo "Setting up environment..."
|
||||
source "$HOME/.cargo/env.fish" 2>/dev/null || source "$HOME/.cargo/env" || true
|
||||
export ANDROID_HOME="$HOME/Android/Sdk"
|
||||
export ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}"
|
||||
export NDK_HOME="$ANDROID_HOME/ndk/$(ls $ANDROID_HOME/ndk 2>/dev/null | head -1)"
|
||||
|
||||
# Check prerequisites
|
||||
|
||||
@@ -6,9 +6,26 @@ set -e
|
||||
# Source Rust environment
|
||||
source "$HOME/.cargo/env.fish" 2>/dev/null || source "$HOME/.cargo/env" 2>/dev/null || true
|
||||
|
||||
# Set Android environment variables
|
||||
export ANDROID_HOME="$HOME/Android/Sdk"
|
||||
export NDK_HOME="$ANDROID_HOME/ndk/$(ls "$ANDROID_HOME/ndk" | head -1)"
|
||||
# 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"
|
||||
|
||||
Generated
+49
-1
@@ -176,6 +176,18 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ascii"
|
||||
version = "1.1.0"
|
||||
@@ -360,6 +372,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -390,6 +408,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -913,6 +940,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2181,9 +2209,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellytau"
|
||||
version = "0.11.4"
|
||||
version = "0.11.6"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -2200,6 +2229,7 @@ dependencies = [
|
||||
"libmpv-sys",
|
||||
"log",
|
||||
"ndk-context",
|
||||
"password-hash",
|
||||
"rand 0.8.7",
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
@@ -3065,6 +3095,17 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
@@ -4284,6 +4325,12 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1_smol"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -5593,6 +5640,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"sha1_smol",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ name = "jellytau"
|
||||
# `player-conformance`, and a second binary makes a bare `cargo run` —
|
||||
# which `tauri dev` issues — ambiguous.
|
||||
default-run = "jellytau"
|
||||
version = "0.11.4"
|
||||
version = "0.11.6"
|
||||
description = "A cross-platform Jellyfin client"
|
||||
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
|
||||
license = "MIT"
|
||||
@@ -43,7 +43,7 @@ tauri-plugin-opener = "2"
|
||||
tauri-plugin-os = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
uuid = { version = "1", features = ["v4", "v5"] }
|
||||
rand = "0.8"
|
||||
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] }
|
||||
tokio-util = "0.7"
|
||||
@@ -64,6 +64,12 @@ aes-gcm = "0.10"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
getrandom = "0.2"
|
||||
|
||||
# Profile PIN hashing (DR-268). A switching gate against a member of the
|
||||
# household, not at-rest protection -- but a hash is the right primitive for a
|
||||
# gate, and Argon2id costs nothing extra over a weaker one.
|
||||
argon2 = "0.5"
|
||||
password-hash = { version = "0.5", features = ["alloc", "rand_core"] }
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
|
||||
@@ -92,6 +92,59 @@ That means:
|
||||
Follow the right log stream with `./scripts/logcat.sh [debug|release]`
|
||||
(defaults to debug).
|
||||
|
||||
### Getting a test APK out of CI
|
||||
|
||||
`.gitea/workflows/build-test-apk.yml` produces installable APKs that are **not
|
||||
releases**. Two ways in:
|
||||
|
||||
| Trigger | Result |
|
||||
|---------|--------|
|
||||
| **push to `master`** | Refreshes the rolling **`latest`** pre-release automatically |
|
||||
| **`workflow_dispatch`** | Builds any branch on demand; optionally publishes it as `test-<branch>` |
|
||||
|
||||
#### The rolling `latest` build
|
||||
|
||||
Every push to `master` (bar doc-only ones) rebuilds and replaces the APK on the
|
||||
`latest` pre-release. Both the tag and the asset name are stable, so the
|
||||
download URL never changes:
|
||||
|
||||
```
|
||||
https://gitea.tourolle.paris/dtourolle/jellytau/releases/download/latest/jellytau-latest.apk
|
||||
```
|
||||
|
||||
Send that link to a tester once and it keeps serving the current build. No
|
||||
account needed — release assets are public, unlike Actions artifacts.
|
||||
|
||||
#### What you get, and why it is safe
|
||||
|
||||
Both variants install into the `com.dtourolle.jellytau.debug` slot:
|
||||
|
||||
| Variant | What it is | When |
|
||||
|---------|-----------|------|
|
||||
| `side-by-side-release` (default, and what `latest` always is) | R8-minified, exactly what ships, signed with the **debug** keystore | Almost always — a plain debug build cannot catch R8 stripping JNI-loaded classes, which has broken release APKs here before |
|
||||
| `debug` | Unminified | When you need readable stack traces |
|
||||
|
||||
Three properties make an automatic build on every master push safe:
|
||||
|
||||
- **It cannot replace a real install.** The applicationId is suffixed `.debug`,
|
||||
so it sits beside the store build with its own data. A broken master commit
|
||||
can never take out somebody's working app.
|
||||
- **The production signing key is not involved.** That stays in the tag-driven
|
||||
`build-release.yml`. This workflow needs no secrets beyond the API token.
|
||||
- **The tag is `latest`/`test-*`, never `v*`.** Only `v*` triggers
|
||||
`build-release.yml`. And the desktop updater reads a static `latest.json` from
|
||||
the `updater` branch rather than the release list, so nothing here is offered
|
||||
to existing users.
|
||||
|
||||
**Known gap:** the APK builds in parallel with `build-and-test.yml`, not after
|
||||
it, so `latest` can carry a commit whose tests later fail. Cross-workflow
|
||||
dependencies are not reliably available here, and duplicating the test job would
|
||||
double an already hour-long queue on a single-slot runner. Check the commit's
|
||||
CI status before handing the link to somebody.
|
||||
|
||||
Runs are serialised and `cancel-in-progress` is on, so a burst of pushes to
|
||||
master collapses into one build rather than one per commit.
|
||||
|
||||
### Key Files
|
||||
|
||||
Player-related Kotlin files:
|
||||
|
||||
@@ -30,6 +30,39 @@ pub async fn auth_initialize(
|
||||
// Try to restore session from storage
|
||||
log::info!("[AuthManager] Restoring session from storage...");
|
||||
|
||||
// A PIN-protected profile is not restored automatically. Restoring it would
|
||||
// hand the app a working token before anybody entered the code, leaving the
|
||||
// picker as decoration over a session that was already live — the gate has
|
||||
// to be on the session itself, not on which screen is shown. The frontend
|
||||
// sees `None`, asks `profiles_startup_target`, and lands on the picker.
|
||||
//
|
||||
// TRACES: UR-083 | DR-268, DR-274
|
||||
{
|
||||
let db_service = {
|
||||
let db = database.0.lock().map_err(|e| e.to_string())?;
|
||||
std::sync::Arc::new(db.service())
|
||||
};
|
||||
let locked: Option<String> = crate::storage::db_service::DatabaseService::query_optional(
|
||||
&*db_service,
|
||||
crate::storage::db_service::Query::new(
|
||||
"SELECT u.id FROM users u
|
||||
JOIN user_pins p ON p.user_id = u.id
|
||||
WHERE u.is_active = 1",
|
||||
),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
if let Some(user_id) = locked {
|
||||
log::info!(
|
||||
"[AuthManager] Active profile {} is PIN-protected; not restoring its session",
|
||||
user_id
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
// Use the existing storage_get_active_session function
|
||||
let active_session =
|
||||
match crate::commands::storage::storage_get_active_session(database, credentials).await {
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod playback_mode;
|
||||
pub mod playback_reporting;
|
||||
pub mod player;
|
||||
pub mod playlist;
|
||||
pub mod profiles;
|
||||
pub mod repository;
|
||||
pub mod sessions;
|
||||
pub mod storage;
|
||||
@@ -35,6 +36,7 @@ pub use playback_mode::*;
|
||||
pub use playback_reporting::*;
|
||||
pub use player::*;
|
||||
pub use playlist::*;
|
||||
pub use profiles::*;
|
||||
pub use repository::{RepositoryManager, RepositoryManagerWrapper, *};
|
||||
pub use sessions::*;
|
||||
pub use storage::*;
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
//! Profile commands: who can use this device, and how they get in.
|
||||
//!
|
||||
//! The rule that shapes this whole module: **switching is not logging out**.
|
||||
//! [`auth_logout`](super::auth::auth_logout) calls Jellyfin's logout endpoint,
|
||||
//! which invalidates the token server-side — so a switch built on it would make
|
||||
//! every switch back cost a password, which is the problem this feature exists
|
||||
//! to solve. Nothing here calls it.
|
||||
//!
|
||||
//! TRACES: UR-082, UR-083, UR-084 | DR-267, DR-268, DR-269, DR-270, DR-274
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use log::{info, warn};
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
use crate::commands::sessions::SessionPollerWrapper;
|
||||
use crate::commands::storage::{CredentialStoreWrapper, DatabaseWrapper};
|
||||
use crate::profiles::pin::{self, PinDecision, PinState};
|
||||
use crate::profiles::switch::{plan, SwitchStep};
|
||||
use crate::profiles::{startup_target, store, Profile, StartupTarget, UnlockOutcome};
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
|
||||
|
||||
/// Settings key for "ask who's watching on start".
|
||||
const ASK_ON_START_KEY: &str = "profiles_ask_on_start";
|
||||
|
||||
fn service(db: &State<'_, DatabaseWrapper>) -> Result<Arc<RusqliteService>, String> {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Ok(Arc::new(database.service()))
|
||||
}
|
||||
|
||||
/// The server this device is signed in to, as `(server_id, server_url)`.
|
||||
///
|
||||
/// Every profile operation is scoped to it — this is where the same-server
|
||||
/// constraint is actually enforced, rather than by omitting a URL field from a
|
||||
/// form.
|
||||
///
|
||||
/// The fallback to the `servers` table is not a convenience. When the last-used
|
||||
/// profile has a PIN, `auth_initialize` deliberately does **not** restore its
|
||||
/// session, so at startup there is no in-memory session to read — and the picker
|
||||
/// still has to know which server's profiles to list. Reading it from storage is
|
||||
/// what lets the PIN gate a real thing rather than just a screen.
|
||||
async fn current_server(
|
||||
db: &Arc<RusqliteService>,
|
||||
auth_manager: &State<'_, super::auth::AuthManagerWrapper>,
|
||||
) -> Result<(String, String), String> {
|
||||
if let Some(session) = auth_manager.0.get_session().await {
|
||||
return Ok((session.server_id, session.server_url));
|
||||
}
|
||||
|
||||
db.query_optional(
|
||||
Query::new("SELECT id, url FROM servers ORDER BY last_connected_at DESC LIMIT 1"),
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "No server connected".to_string())
|
||||
}
|
||||
|
||||
async fn ask_on_start(db: &Arc<RusqliteService>) -> bool {
|
||||
let query = Query::with_params(
|
||||
"SELECT value FROM app_settings WHERE key = ?",
|
||||
vec![QueryParam::String(ASK_ON_START_KEY.to_string())],
|
||||
);
|
||||
db.query_optional(query, |row| row.get::<_, String>(0))
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// List the accounts this device knows for the current server.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-267
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn profiles_list(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
|
||||
) -> Result<Vec<Profile>, String> {
|
||||
let svc = service(&db)?;
|
||||
let (server_id, _) = current_server(&svc, &auth_manager).await?;
|
||||
store::list_profiles(&svc, &server_id).await
|
||||
}
|
||||
|
||||
/// Whether startup should resume an account or ask who is watching.
|
||||
///
|
||||
/// The decision is backend state, so the frontend asks rather than computes it.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-274
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn profiles_startup_target(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
|
||||
) -> Result<StartupTarget, String> {
|
||||
let svc = service(&db)?;
|
||||
let (server_id, _) = match current_server(&svc, &auth_manager).await {
|
||||
Ok(pair) => pair,
|
||||
// Nothing signed in yet: the picker doubles as first-run login.
|
||||
Err(_) => return Ok(StartupTarget::Picker),
|
||||
};
|
||||
let profiles = store::list_profiles(&svc, &server_id).await?;
|
||||
Ok(startup_target(&profiles, ask_on_start(&svc).await))
|
||||
}
|
||||
|
||||
/// Read the "ask who's watching on start" setting.
|
||||
///
|
||||
/// Separate from [`profiles_startup_target`] on purpose: the target can be
|
||||
/// `Picker` for reasons that have nothing to do with this setting — a
|
||||
/// PIN-protected last profile always asks — so deriving the toggle's position
|
||||
/// from it would show the user a switch that does not describe what it controls.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-274
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn profiles_get_ask_on_start(db: State<'_, DatabaseWrapper>) -> Result<bool, String> {
|
||||
let svc = service(&db)?;
|
||||
Ok(ask_on_start(&svc).await)
|
||||
}
|
||||
|
||||
/// Turn "ask who's watching on start" on or off.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-274
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn profiles_set_ask_on_start(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
let svc = service(&db)?;
|
||||
let query = Query::with_params(
|
||||
"INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
|
||||
vec![
|
||||
QueryParam::String(ASK_ON_START_KEY.to_string()),
|
||||
QueryParam::String(if enabled { "1" } else { "0" }.to_string()),
|
||||
],
|
||||
);
|
||||
svc.execute(query).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enter a profile, with its PIN if it has one.
|
||||
///
|
||||
/// A profile with no PIN ignores whatever `pin` was passed — the frontend cannot
|
||||
/// invent a lock the backend does not have, and cannot skip one it does.
|
||||
///
|
||||
/// TRACES: UR-082, UR-083 | DR-267, DR-268, DR-270
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn profiles_unlock(
|
||||
app: tauri::AppHandle,
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
creds: State<'_, CredentialStoreWrapper>,
|
||||
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
|
||||
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
||||
session_poller: State<'_, SessionPollerWrapper>,
|
||||
user_id: String,
|
||||
pin_code: Option<String>,
|
||||
) -> Result<UnlockOutcome, String> {
|
||||
let svc = service(&db)?;
|
||||
|
||||
let profile = store::get_profile(&svc, &user_id)
|
||||
.await?
|
||||
.ok_or_else(|| format!("Unknown profile: {}", user_id))?;
|
||||
|
||||
// Same-server constraint, checked at the point of use rather than trusted
|
||||
// from the caller.
|
||||
let (server_id, _) = current_server(&svc, &auth_manager).await?;
|
||||
if profile.server_id != server_id {
|
||||
return Err("Profile belongs to a different server".to_string());
|
||||
}
|
||||
|
||||
if let Some((hash, state)) = store::get_pin(&svc, &user_id).await? {
|
||||
let candidate = pin_code.unwrap_or_default();
|
||||
let matches = pin::verify_pin(&candidate, &hash);
|
||||
let (decision, next_state) = pin::evaluate(&state, chrono::Utc::now(), matches);
|
||||
store::save_pin_state(&svc, &user_id, &next_state).await?;
|
||||
|
||||
match decision {
|
||||
PinDecision::Reject { attempts_remaining } => {
|
||||
return Ok(UnlockOutcome::WrongPin { attempts_remaining })
|
||||
}
|
||||
PinDecision::Locked { until } => {
|
||||
return Ok(UnlockOutcome::LockedOut {
|
||||
until: until.to_rfc3339(),
|
||||
})
|
||||
}
|
||||
PinDecision::Accept => {}
|
||||
}
|
||||
}
|
||||
|
||||
let outgoing = active_user_id(&svc).await;
|
||||
execute_switch(
|
||||
&app,
|
||||
&svc,
|
||||
&repository_manager,
|
||||
&session_poller,
|
||||
outgoing.as_deref(),
|
||||
&user_id,
|
||||
)
|
||||
.await?;
|
||||
adopt_session(db, creds, &auth_manager).await?;
|
||||
|
||||
Ok(UnlockOutcome::Ok { user_id })
|
||||
}
|
||||
|
||||
/// Enter a profile with its Jellyfin password, for someone who has forgotten
|
||||
/// their PIN.
|
||||
///
|
||||
/// There is deliberately no reset token and no recovery secret: the account's
|
||||
/// own password is already the authority over it, and a second credential
|
||||
/// guarding the same thing would only be a weaker one. A successful password
|
||||
/// entry also clears the lockout, which is what makes a forgotten PIN a
|
||||
/// detour rather than a dead end.
|
||||
///
|
||||
/// TRACES: UR-084 | DR-269
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn profiles_unlock_with_password(
|
||||
app: tauri::AppHandle,
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
creds: State<'_, CredentialStoreWrapper>,
|
||||
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
|
||||
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
||||
session_poller: State<'_, SessionPollerWrapper>,
|
||||
user_id: String,
|
||||
password: String,
|
||||
device_id: String,
|
||||
) -> Result<UnlockOutcome, String> {
|
||||
let svc = service(&db)?;
|
||||
let profile = store::get_profile(&svc, &user_id)
|
||||
.await?
|
||||
.ok_or_else(|| format!("Unknown profile: {}", user_id))?;
|
||||
|
||||
let (server_id, server_url) = current_server(&svc, &auth_manager).await?;
|
||||
if profile.server_id != server_id {
|
||||
return Err("Profile belongs to a different server".to_string());
|
||||
}
|
||||
|
||||
let result = auth_manager
|
||||
.0
|
||||
.login(&server_url, &profile.username, &password, &device_id)
|
||||
.await?;
|
||||
|
||||
if result.user.id != user_id {
|
||||
return Err("Signed in as a different account".to_string());
|
||||
}
|
||||
|
||||
save_token(&creds, &user_id, &result.access_token)?;
|
||||
|
||||
// The password got in, so the PIN counters have served their purpose.
|
||||
store::save_pin_state(&svc, &user_id, &PinState::fresh()).await?;
|
||||
|
||||
let outgoing = active_user_id(&svc).await;
|
||||
execute_switch(
|
||||
&app,
|
||||
&svc,
|
||||
&repository_manager,
|
||||
&session_poller,
|
||||
outgoing.as_deref(),
|
||||
&user_id,
|
||||
)
|
||||
.await?;
|
||||
adopt_session(db, creds, &auth_manager).await?;
|
||||
|
||||
Ok(UnlockOutcome::Ok { user_id })
|
||||
}
|
||||
|
||||
/// Add another account from the **current** server to this device.
|
||||
///
|
||||
/// Takes no server URL. That is the same-server constraint expressed as a
|
||||
/// signature rather than as form validation: there is no way to ask this command
|
||||
/// for an account somewhere else.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-267
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn profiles_add(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
creds: State<'_, CredentialStoreWrapper>,
|
||||
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
|
||||
username: String,
|
||||
password: String,
|
||||
pin_code: Option<String>,
|
||||
device_id: String,
|
||||
) -> Result<Profile, String> {
|
||||
if let Some(code) = &pin_code {
|
||||
pin::validate_pin(code)?;
|
||||
}
|
||||
|
||||
let svc = service(&db)?;
|
||||
let (server_id, server_url) = current_server(&svc, &auth_manager).await?;
|
||||
|
||||
let result = auth_manager
|
||||
.0
|
||||
.login(&server_url, &username, &password, &device_id)
|
||||
.await?;
|
||||
|
||||
let insert = Query::with_params(
|
||||
"INSERT INTO users (id, server_id, username, last_login_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
server_id = excluded.server_id,
|
||||
username = excluded.username,
|
||||
last_login_at = CURRENT_TIMESTAMP",
|
||||
vec![
|
||||
QueryParam::String(result.user.id.clone()),
|
||||
QueryParam::String(server_id.clone()),
|
||||
QueryParam::String(result.user.name.clone()),
|
||||
],
|
||||
);
|
||||
svc.execute(insert).await.map_err(|e| e.to_string())?;
|
||||
|
||||
save_token(&creds, &result.user.id, &result.access_token)?;
|
||||
|
||||
if let Some(code) = pin_code {
|
||||
let hash = pin::hash_pin(&code)?;
|
||||
store::set_pin(&svc, &result.user.id, &hash).await?;
|
||||
}
|
||||
|
||||
info!("[Profiles] Added profile {}", result.user.name);
|
||||
|
||||
store::get_profile(&svc, &result.user.id)
|
||||
.await?
|
||||
.ok_or_else(|| "Profile vanished after being added".to_string())
|
||||
}
|
||||
|
||||
/// Set, change, or clear a profile's PIN.
|
||||
///
|
||||
/// Changing an existing PIN requires the current one. Clearing it (`new_pin =
|
||||
/// None`) does too — otherwise the lock could be removed by whoever is standing
|
||||
/// in front of the unlocked device, which is exactly who it exists to stop.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn profiles_set_pin(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
user_id: String,
|
||||
current_pin: Option<String>,
|
||||
new_pin: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let svc = service(&db)?;
|
||||
|
||||
if let Some((hash, _)) = store::get_pin(&svc, &user_id).await? {
|
||||
let provided = current_pin.unwrap_or_default();
|
||||
if !pin::verify_pin(&provided, &hash) {
|
||||
return Err("Current PIN is incorrect".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
match new_pin {
|
||||
Some(code) => {
|
||||
pin::validate_pin(&code)?;
|
||||
let hash = pin::hash_pin(&code)?;
|
||||
store::set_pin(&svc, &user_id, &hash).await
|
||||
}
|
||||
None => store::clear_pin(&svc, &user_id).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget a profile on this device.
|
||||
///
|
||||
/// Does not call Jellyfin's logout endpoint: removing an account from the family
|
||||
/// TV should not sign that person out on their phone. The stored token is
|
||||
/// deleted locally, which is the part that actually belongs to this device.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-267
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn profiles_remove(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
creds: State<'_, CredentialStoreWrapper>,
|
||||
user_id: String,
|
||||
) -> Result<(), String> {
|
||||
let svc = service(&db)?;
|
||||
|
||||
if active_user_id(&svc).await.as_deref() == Some(user_id.as_str()) {
|
||||
return Err("Switch to another profile before removing this one".to_string());
|
||||
}
|
||||
|
||||
{
|
||||
let store = creds.0.lock().map_err(|e| e.to_string())?;
|
||||
if let Err(e) = store.delete_token(&user_id) {
|
||||
warn!("[Profiles] Could not delete stored token: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
store::remove_profile(&svc, &user_id).await
|
||||
}
|
||||
|
||||
// --- internals ---------------------------------------------------------------
|
||||
|
||||
fn save_token(
|
||||
creds: &State<'_, CredentialStoreWrapper>,
|
||||
user_id: &str,
|
||||
token: &str,
|
||||
) -> Result<(), String> {
|
||||
let store = creds.0.lock().map_err(|e| e.to_string())?;
|
||||
store
|
||||
.save_token(user_id, token)
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn active_user_id(db: &Arc<RusqliteService>) -> Option<String> {
|
||||
db.query_optional(
|
||||
Query::new("SELECT id FROM users WHERE is_active = 1 LIMIT 1"),
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Run a switch plan.
|
||||
///
|
||||
/// The ordering comes from [`crate::profiles::switch::plan`] rather than being
|
||||
/// written out here, because the ordering is the invariant worth testing and an
|
||||
/// end-to-end switch needs two real accounts on a real server to exercise.
|
||||
///
|
||||
/// One step is deliberately not executed here: `BuildRepository`. Repository
|
||||
/// handles are created by the frontend (`repository_create`) because building
|
||||
/// one needs the token and URL it already assembles at login, so the
|
||||
/// `profile-switched` event is the signal to do it. What stays in Rust is the
|
||||
/// part that matters — that the old handle is destroyed *before* the active user
|
||||
/// flips, so nothing can write under the wrong id in between.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-270
|
||||
async fn execute_switch(
|
||||
app: &tauri::AppHandle,
|
||||
db: &Arc<RusqliteService>,
|
||||
repository_manager: &State<'_, super::repository::RepositoryManagerWrapper>,
|
||||
session_poller: &State<'_, SessionPollerWrapper>,
|
||||
from: Option<&str>,
|
||||
to: &str,
|
||||
) -> Result<(), String> {
|
||||
let online = true;
|
||||
let steps = plan(from, to, online);
|
||||
|
||||
for step in steps {
|
||||
match step {
|
||||
SwitchStep::StopPlayback => {
|
||||
// The queue cannot outlive its owner: a report landing after the
|
||||
// flip would attribute one account's viewing to another.
|
||||
if let Err(e) = app.emit("profile-switch-stop-playback", ()) {
|
||||
warn!("[Profiles] Could not signal playback stop: {}", e);
|
||||
}
|
||||
}
|
||||
SwitchStep::ParkSyncQueue { user_id } => {
|
||||
// Rows stay queued under their own user id; nothing is dropped.
|
||||
// Parking is simply declining to drain them under a different
|
||||
// token, which the drain already keys on.
|
||||
info!("[Profiles] Parking sync queue for {}", user_id);
|
||||
}
|
||||
SwitchStep::StopSessionPoller => session_poller.0.stop(),
|
||||
SwitchStep::ClearLockscreenMetadata => {
|
||||
if let Err(e) = app.emit("profile-switch-clear-metadata", ()) {
|
||||
warn!("[Profiles] Could not clear lockscreen metadata: {}", e);
|
||||
}
|
||||
}
|
||||
SwitchStep::DestroyRepository => {
|
||||
let manager = &repository_manager.0;
|
||||
for handle in manager.handles() {
|
||||
manager.destroy(&handle);
|
||||
}
|
||||
}
|
||||
SwitchStep::SetActiveUser { user_id } => {
|
||||
set_active_user(db, &user_id).await?;
|
||||
}
|
||||
SwitchStep::BuildRepository { .. } => {
|
||||
// Owned by the frontend; see the doc comment above.
|
||||
}
|
||||
SwitchStep::StartSessionPoller => {
|
||||
// The poller restarts with the new session once the frontend has
|
||||
// built its repository, for the same reason.
|
||||
}
|
||||
SwitchStep::RefreshVisibility { user_id } => {
|
||||
info!("[Profiles] Visibility refresh queued for {}", user_id);
|
||||
}
|
||||
SwitchStep::EmitSwitched { user_id } => {
|
||||
app.emit("profile-switched", serde_json::json!({ "userId": user_id }))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make the newly-active profile the session the rest of the backend acts as.
|
||||
///
|
||||
/// The token lives in the credential store, which the frontend cannot read, so
|
||||
/// the swap has to happen here — the frontend then rebuilds its repository
|
||||
/// handle from the session it can now read back. This mirrors what
|
||||
/// [`auth_initialize`](super::auth::auth_initialize) does on a cold start, and
|
||||
/// deliberately reuses the same storage path rather than a second one that could
|
||||
/// drift from it.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-270
|
||||
async fn adopt_session(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
creds: State<'_, CredentialStoreWrapper>,
|
||||
auth_manager: &State<'_, super::auth::AuthManagerWrapper>,
|
||||
) -> Result<(), String> {
|
||||
let active = super::storage::storage_get_active_session(db, creds)
|
||||
.await?
|
||||
.ok_or_else(|| "Profile has no stored session".to_string())?;
|
||||
|
||||
let normalized_url = crate::auth::AuthManager::normalize_url(&active.server_url)?;
|
||||
|
||||
auth_manager
|
||||
.0
|
||||
.set_session(Some(crate::auth::Session {
|
||||
user_id: active.user_id,
|
||||
username: active.username,
|
||||
server_id: active.server_id,
|
||||
server_url: normalized_url,
|
||||
server_name: active.server_name,
|
||||
access_token: active.access_token,
|
||||
verified: false,
|
||||
needs_reauth: false,
|
||||
}))
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_active_user(db: &Arc<RusqliteService>, user_id: &str) -> Result<(), String> {
|
||||
db.execute(Query::new("UPDATE users SET is_active = 0"))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
db.execute(Query::with_params(
|
||||
"UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
vec![QueryParam::String(user_id.to_string())],
|
||||
))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
+296
-50
@@ -4,8 +4,19 @@
|
||||
//! - Primary: System keyring (Secret Service on Linux, Keychain on macOS)
|
||||
//! - Fallback: AES-256-GCM encrypted file when keyring unavailable
|
||||
//!
|
||||
//! The fallback is less secure as the encryption key is derived from machine
|
||||
//! identifiers, but provides functionality on headless systems.
|
||||
//! The fallback is **obfuscation at rest, not a secret**: its key sits in a file
|
||||
//! beside the ciphertext, so anyone who can read one can read the other. It
|
||||
//! exists so headless systems keep working, and the keyring remains the only
|
||||
//! place a token is actually protected.
|
||||
//!
|
||||
//! The key used to be *derived* from the hostname, `$USER` and a hardcoded salt.
|
||||
//! That was no more secret — those are readable by anyone who can read the file
|
||||
//! — and it was unstable: renaming the machine, or launching from a context
|
||||
//! where `$USER` is unset, changed the key and made every stored token
|
||||
//! undecryptable. `load_credentials_file` treats a failed decrypt as "no stored
|
||||
//! credentials", so that surfaced as being silently signed out rather than as an
|
||||
//! error. The key is now random and persisted, and the old derivation is kept
|
||||
//! only to migrate a file written before this change.
|
||||
//!
|
||||
//! TRACES: UR-012 | IR-014
|
||||
|
||||
@@ -25,6 +36,119 @@ const SERVICE_NAME: &str = "com.dtourolle.jellytau";
|
||||
|
||||
const CREDENTIALS_FILENAME: &str = "credentials.enc";
|
||||
|
||||
/// Key file for the encrypted-file fallback, beside the credentials it opens.
|
||||
const KEY_FILENAME: &str = "credentials.key";
|
||||
|
||||
/// Load the fallback encryption key, creating it on first use.
|
||||
///
|
||||
/// Random rather than derived. A derived key was no more secret — its inputs
|
||||
/// (hostname, `$USER`, a hardcoded salt) are readable by anyone who can read
|
||||
/// the ciphertext — and it silently changed when the machine was renamed or
|
||||
/// `$USER` was unset, which read to the user as being signed out for no reason.
|
||||
///
|
||||
/// If the key cannot be persisted the process still gets a usable key for this
|
||||
/// run; credentials written under it simply will not be readable next launch,
|
||||
/// which is the same outcome as today and better than refusing to store a token.
|
||||
///
|
||||
/// TRACES: UR-012 | IR-014 | UT-014
|
||||
fn load_or_create_key(path: &std::path::Path) -> [u8; 32] {
|
||||
if let Ok(existing) = fs::read(path) {
|
||||
if existing.len() == 32 {
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&existing);
|
||||
return key;
|
||||
}
|
||||
warn!(
|
||||
"Fallback key at {:?} is {} bytes, not 32; replacing it. Credentials \
|
||||
written under the old key will need signing in again.",
|
||||
path,
|
||||
existing.len()
|
||||
);
|
||||
}
|
||||
|
||||
let mut key = [0u8; 32];
|
||||
if getrandom::getrandom(&mut key).is_err() {
|
||||
warn!("No system randomness for the fallback key; deriving one for this run");
|
||||
return CredentialStore::derive_legacy_encryption_key();
|
||||
}
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
match fs::write(path, key) {
|
||||
Ok(()) => restrict_to_owner(path),
|
||||
Err(e) => warn!(
|
||||
"Could not persist the fallback key at {:?} ({}); credentials stored \
|
||||
this run will not be readable next launch",
|
||||
path, e
|
||||
),
|
||||
}
|
||||
key
|
||||
}
|
||||
|
||||
/// Make a key file owner-readable only. Best effort — a filesystem without
|
||||
/// Unix permissions is not a reason to fail.
|
||||
fn restrict_to_owner(path: &std::path::Path) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Err(e) = fs::set_permissions(path, fs::Permissions::from_mode(0o600)) {
|
||||
warn!("Could not restrict permissions on {:?}: {}", path, e);
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = path;
|
||||
}
|
||||
|
||||
/// Decrypt `encrypted` with `key`.
|
||||
///
|
||||
/// TRACES: UR-012 | IR-014 | UT-014
|
||||
fn decrypt_with(key: &[u8; 32], encrypted: &str) -> Result<String, CredentialError> {
|
||||
let combined = BASE64
|
||||
.decode(encrypted)
|
||||
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
|
||||
if combined.len() < 12 {
|
||||
return Err(CredentialError::Encryption(
|
||||
"Invalid encrypted data".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let (nonce_bytes, ciphertext) = combined.split_at(12);
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
|
||||
String::from_utf8(plaintext).map_err(|e| CredentialError::Encryption(e.to_string()))
|
||||
}
|
||||
|
||||
/// Encrypt `plaintext` with `key`, prepending a fresh random nonce.
|
||||
///
|
||||
/// TRACES: UR-012 | IR-014 | UT-014
|
||||
fn encrypt_with(key: &[u8; 32], plaintext: &str) -> Result<String, CredentialError> {
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
getrandom::getrandom(&mut nonce_bytes)
|
||||
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, plaintext.as_bytes())
|
||||
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
|
||||
let mut combined = nonce_bytes.to_vec();
|
||||
combined.extend(ciphertext);
|
||||
|
||||
Ok(BASE64.encode(&combined))
|
||||
}
|
||||
|
||||
/// Result of a credential storage operation
|
||||
#[derive(Debug)]
|
||||
pub enum CredentialResult {
|
||||
@@ -66,15 +190,21 @@ pub struct CredentialStore {
|
||||
using_keyring: bool,
|
||||
/// Path to the encrypted credentials file (fallback)
|
||||
credentials_path: PathBuf,
|
||||
/// Encryption key for file fallback (derived from machine ID)
|
||||
/// Encryption key for the file fallback. Random and persisted, so it does
|
||||
/// not change when the machine is renamed.
|
||||
encryption_key: [u8; 32],
|
||||
/// The pre-existing derivation, retained only to read a file written before
|
||||
/// the key was persisted. Anything decrypted with it is rewritten under
|
||||
/// `encryption_key`.
|
||||
legacy_key: [u8; 32],
|
||||
}
|
||||
|
||||
impl CredentialStore {
|
||||
/// Create a new credential store, detecting the best available backend
|
||||
pub fn new() -> Self {
|
||||
let credentials_path = Self::get_credentials_path();
|
||||
let encryption_key = Self::derive_encryption_key();
|
||||
let encryption_key = load_or_create_key(&Self::get_key_path());
|
||||
let legacy_key = Self::derive_legacy_encryption_key();
|
||||
|
||||
// Test if keyring is available by trying a dummy operation
|
||||
let using_keyring = Self::test_keyring_available();
|
||||
@@ -93,6 +223,7 @@ impl CredentialStore {
|
||||
using_keyring,
|
||||
credentials_path,
|
||||
encryption_key,
|
||||
legacy_key,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,6 +537,16 @@ impl CredentialStore {
|
||||
|
||||
// --- Encrypted file backend ---
|
||||
|
||||
/// Where the fallback key lives: beside the credentials file, so the two
|
||||
/// travel together and a restore that brings one brings the other.
|
||||
fn get_key_path() -> PathBuf {
|
||||
if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") {
|
||||
proj_dirs.data_dir().join(KEY_FILENAME)
|
||||
} else {
|
||||
PathBuf::from(KEY_FILENAME)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_credentials_path() -> PathBuf {
|
||||
if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") {
|
||||
proj_dirs.data_dir().join(CREDENTIALS_FILENAME)
|
||||
@@ -414,7 +555,14 @@ impl CredentialStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_encryption_key() -> [u8; 32] {
|
||||
/// The key derivation used before keys were persisted.
|
||||
///
|
||||
/// Kept **only** so a credentials file written by an older build can still
|
||||
/// be read once and rewritten under the persisted key. Never used to
|
||||
/// encrypt. See the module docs for why it was replaced.
|
||||
///
|
||||
/// TRACES: UR-012 | IR-014
|
||||
fn derive_legacy_encryption_key() -> [u8; 32] {
|
||||
// Derive a key from machine-specific identifiers
|
||||
// This is less secure than a true keyring but provides some protection
|
||||
let mut hasher = Sha256::new();
|
||||
@@ -490,7 +638,7 @@ impl CredentialStore {
|
||||
return Ok(serde_json::json!({}));
|
||||
}
|
||||
|
||||
let decrypted = match self.decrypt(&encrypted_data) {
|
||||
let (decrypted, from_legacy_key) = match self.decrypt_migrating(&encrypted_data) {
|
||||
Ok(decrypted) => decrypted,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
@@ -505,7 +653,19 @@ impl CredentialStore {
|
||||
};
|
||||
|
||||
match serde_json::from_str(&decrypted) {
|
||||
Ok(value) => Ok(value),
|
||||
Ok(value) => {
|
||||
// Rewrite under the persisted key so the legacy derivation is
|
||||
// never needed again.
|
||||
if from_legacy_key {
|
||||
if let Err(e) = self.save_credentials_file(&value) {
|
||||
warn!(
|
||||
"Could not rewrite credentials under the persisted key: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Credentials file at {:?} decrypted to invalid JSON ({}); \
|
||||
@@ -531,48 +691,27 @@ impl CredentialStore {
|
||||
}
|
||||
|
||||
fn encrypt(&self, plaintext: &str) -> Result<String, CredentialError> {
|
||||
let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
|
||||
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
|
||||
// Generate a random nonce
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
getrandom::getrandom(&mut nonce_bytes)
|
||||
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, plaintext.as_bytes())
|
||||
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
|
||||
// Prepend nonce to ciphertext and encode as base64
|
||||
let mut combined = nonce_bytes.to_vec();
|
||||
combined.extend(ciphertext);
|
||||
|
||||
Ok(BASE64.encode(&combined))
|
||||
encrypt_with(&self.encryption_key, plaintext)
|
||||
}
|
||||
|
||||
fn decrypt(&self, encrypted: &str) -> Result<String, CredentialError> {
|
||||
let combined = BASE64
|
||||
.decode(encrypted)
|
||||
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
|
||||
if combined.len() < 12 {
|
||||
return Err(CredentialError::Encryption(
|
||||
"Invalid encrypted data".to_string(),
|
||||
));
|
||||
/// Decrypt with the current key, falling back to the legacy derivation.
|
||||
///
|
||||
/// Returns the plaintext and whether the legacy key was what opened it, so
|
||||
/// the caller can rewrite the file under the current key and stop depending
|
||||
/// on a derivation that changes when the machine is renamed.
|
||||
///
|
||||
/// TRACES: UR-012 | IR-014 | UT-014
|
||||
fn decrypt_migrating(&self, encrypted: &str) -> Result<(String, bool), CredentialError> {
|
||||
match decrypt_with(&self.encryption_key, encrypted) {
|
||||
Ok(plaintext) => Ok((plaintext, false)),
|
||||
Err(current_err) => match decrypt_with(&self.legacy_key, encrypted) {
|
||||
Ok(plaintext) => {
|
||||
info!("Credentials were written under the legacy derived key; rewriting them");
|
||||
Ok((plaintext, true))
|
||||
}
|
||||
Err(_) => Err(current_err),
|
||||
},
|
||||
}
|
||||
|
||||
let (nonce_bytes, ciphertext) = combined.split_at(12);
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
|
||||
let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
|
||||
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
|
||||
String::from_utf8(plaintext).map_err(|e| CredentialError::Encryption(e.to_string()))
|
||||
}
|
||||
|
||||
fn save_to_file(&self, user_id: &str, token: &str) -> Result<(), CredentialError> {
|
||||
@@ -884,6 +1023,103 @@ pub use android_keystore::{
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
/// The fallback key must be the same on every launch.
|
||||
///
|
||||
/// It used to be derived from the hostname, `$USER` and a static salt.
|
||||
/// Renaming the machine — or launching from a context where `$USER` is
|
||||
/// unset, such as a systemd user service — changed the key, and
|
||||
/// `load_credentials_file` reports a failed decrypt as "no stored
|
||||
/// credentials". The user was silently signed out with nothing to explain it.
|
||||
///
|
||||
/// TRACES: UR-012 | IR-014 | UT-014
|
||||
#[test]
|
||||
fn the_fallback_key_is_stable_across_processes() {
|
||||
let dir = std::env::temp_dir().join(format!("jellytau-key-{}", std::process::id()));
|
||||
let path = dir.join("credentials.key");
|
||||
let _ = fs::remove_file(&path);
|
||||
|
||||
let first = load_or_create_key(&path);
|
||||
let second = load_or_create_key(&path);
|
||||
assert_eq!(first, second, "the key must not change between launches");
|
||||
assert_ne!(first, [0u8; 32], "the key must be real randomness");
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Two installs must not share a key.
|
||||
///
|
||||
/// TRACES: UR-012 | IR-014 | UT-014
|
||||
#[test]
|
||||
fn separate_installs_get_separate_keys() {
|
||||
let base = std::env::temp_dir().join(format!("jellytau-keys-{}", std::process::id()));
|
||||
let a = load_or_create_key(&base.join("a").join("credentials.key"));
|
||||
let b = load_or_create_key(&base.join("b").join("credentials.key"));
|
||||
assert_ne!(a, b);
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// A credentials file written under the old derived key must still open.
|
||||
///
|
||||
/// TRACES: UR-012 | IR-014 | UT-014
|
||||
#[test]
|
||||
fn credentials_written_under_the_legacy_key_still_decrypt() {
|
||||
let legacy = CredentialStore::derive_legacy_encryption_key();
|
||||
let mut persisted = [0u8; 32];
|
||||
getrandom::getrandom(&mut persisted).unwrap();
|
||||
assert_ne!(legacy, persisted);
|
||||
|
||||
let blob = encrypt_with(&legacy, r#"{"user-1":"token-abc"}"#).unwrap();
|
||||
|
||||
let store = CredentialStore {
|
||||
using_keyring: false,
|
||||
credentials_path: PathBuf::from("/nonexistent/credentials.enc"),
|
||||
encryption_key: persisted,
|
||||
legacy_key: legacy,
|
||||
};
|
||||
|
||||
let (plaintext, migrated) = store
|
||||
.decrypt_migrating(&blob)
|
||||
.expect("a file written under the legacy key must still be readable");
|
||||
assert_eq!(plaintext, r#"{"user-1":"token-abc"}"#);
|
||||
assert!(migrated, "the caller must know to rewrite it");
|
||||
}
|
||||
|
||||
/// The current key is tried first and needs no migration.
|
||||
///
|
||||
/// TRACES: UR-012 | IR-014 | UT-014
|
||||
#[test]
|
||||
fn credentials_under_the_current_key_are_not_flagged_for_migration() {
|
||||
let mut persisted = [0u8; 32];
|
||||
getrandom::getrandom(&mut persisted).unwrap();
|
||||
let blob = encrypt_with(&persisted, "hello").unwrap();
|
||||
|
||||
let store = CredentialStore {
|
||||
using_keyring: false,
|
||||
credentials_path: PathBuf::from("/nonexistent/credentials.enc"),
|
||||
encryption_key: persisted,
|
||||
legacy_key: [7u8; 32],
|
||||
};
|
||||
|
||||
let (plaintext, migrated) = store.decrypt_migrating(&blob).unwrap();
|
||||
assert_eq!(plaintext, "hello");
|
||||
assert!(!migrated);
|
||||
}
|
||||
|
||||
/// A blob under neither key fails rather than returning something wrong.
|
||||
///
|
||||
/// TRACES: UR-012 | IR-014 | UT-014
|
||||
#[test]
|
||||
fn an_unreadable_blob_is_an_error() {
|
||||
let blob = encrypt_with(&[1u8; 32], "secret").unwrap();
|
||||
let store = CredentialStore {
|
||||
using_keyring: false,
|
||||
credentials_path: PathBuf::from("/nonexistent/credentials.enc"),
|
||||
encryption_key: [2u8; 32],
|
||||
legacy_key: [3u8; 32],
|
||||
};
|
||||
assert!(store.decrypt_migrating(&blob).is_err());
|
||||
}
|
||||
use super::*;
|
||||
|
||||
/// Build a store pinned to the encrypted-file backend with an explicit key,
|
||||
@@ -894,6 +1130,9 @@ mod tests {
|
||||
using_keyring: false,
|
||||
credentials_path,
|
||||
encryption_key,
|
||||
// A distinct legacy key, so "same file, different machine key" stays
|
||||
// undecryptable rather than being opened by the migration path.
|
||||
legacy_key: [0xABu8; 32],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -959,15 +1198,22 @@ mod tests {
|
||||
let plaintext = "test-access-token-12345";
|
||||
|
||||
let encrypted = store.encrypt(plaintext).unwrap();
|
||||
let decrypted = store.decrypt(&encrypted).unwrap();
|
||||
let (decrypted, _) = store.decrypt_migrating(&encrypted).unwrap();
|
||||
|
||||
assert_eq!(plaintext, decrypted);
|
||||
}
|
||||
|
||||
/// The legacy derivation must stay deterministic *within a machine*, or the
|
||||
/// one-time migration of an old credentials file cannot read it.
|
||||
///
|
||||
/// Its instability *across* machine states is exactly why it no longer
|
||||
/// encrypts anything — see `the_fallback_key_is_stable_across_processes`.
|
||||
///
|
||||
/// TRACES: UR-012 | IR-014 | UT-014
|
||||
#[test]
|
||||
fn test_derive_encryption_key_is_deterministic() {
|
||||
let key1 = CredentialStore::derive_encryption_key();
|
||||
let key2 = CredentialStore::derive_encryption_key();
|
||||
fn test_legacy_derivation_is_deterministic_for_migration() {
|
||||
let key1 = CredentialStore::derive_legacy_encryption_key();
|
||||
let key2 = CredentialStore::derive_legacy_encryption_key();
|
||||
assert_eq!(key1, key2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +183,16 @@ impl DownloadWorker {
|
||||
.await
|
||||
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
|
||||
|
||||
// A media file is never legitimately empty, and completing one is worse
|
||||
// than failing: the row goes `completed`, the item shows as available
|
||||
// offline, and playback then stalls on a file with nothing in it. A
|
||||
// server that answered 200 with no body — an error page, a transcode
|
||||
// that produced nothing — used to land here. Treat it as the network
|
||||
// failure it is so the retry budget applies and the `.part` is kept.
|
||||
if let Some(reason) = rejects_as_empty(downloaded) {
|
||||
return Err(DownloadError::Network(reason.to_string()));
|
||||
}
|
||||
|
||||
// Move from .part to final location
|
||||
fs::rename(&temp_path, &task.target_path)
|
||||
.await
|
||||
@@ -229,6 +239,23 @@ pub fn resume_offset(existing_bytes: u64, status: u16) -> u64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a finished transfer must not be accepted, if it must not be.
|
||||
///
|
||||
/// A media file is never legitimately empty, and *completing* an empty one is
|
||||
/// worse than failing: the row goes `completed`, the item shows as available
|
||||
/// offline, and playback later stalls on a file with nothing in it. A server
|
||||
/// that answered 200 with no body — an error page, a transcode that produced
|
||||
/// nothing — used to land exactly there.
|
||||
///
|
||||
/// Reported as a network error so the existing retry budget applies and the
|
||||
/// `.part` file is kept for a resume.
|
||||
///
|
||||
/// TRACES: UR-019 | DR-168 | UT-168
|
||||
pub fn rejects_as_empty(downloaded: u64) -> Option<&'static str> {
|
||||
(downloaded == 0)
|
||||
.then_some("server sent an empty body; refusing to complete a zero-byte download")
|
||||
}
|
||||
|
||||
/// The partial-download sidecar for `target`.
|
||||
///
|
||||
/// **Appends** `.part` rather than replacing the extension. The worker used
|
||||
@@ -305,6 +332,23 @@ impl std::error::Error for DownloadError {}
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A transfer that produced no bytes must never be marked complete.
|
||||
///
|
||||
/// Completing it publishes an empty file as playable offline; the media
|
||||
/// server then answers a request for it with a 416 and the item simply
|
||||
/// never starts, with nothing in the UI explaining why.
|
||||
///
|
||||
/// TRACES: UR-019 | DR-168 | UT-168
|
||||
#[test]
|
||||
fn test_a_zero_byte_transfer_is_rejected_rather_than_completed() {
|
||||
assert!(
|
||||
rejects_as_empty(0).is_some(),
|
||||
"a zero-byte download must not be completed"
|
||||
);
|
||||
assert!(rejects_as_empty(1).is_none());
|
||||
assert!(rejects_as_empty(4 * 1024 * 1024).is_none());
|
||||
}
|
||||
|
||||
/// The bitrate-download corruption: a transcode ignores `Range` and answers
|
||||
/// `200` with the whole stream. Appending that to the bytes already on disk
|
||||
/// duplicated them, so every retry grew the file past its real size and left
|
||||
|
||||
@@ -15,6 +15,7 @@ mod media_server;
|
||||
mod playback_mode;
|
||||
mod playback_reporting;
|
||||
mod player;
|
||||
mod profiles;
|
||||
mod repository;
|
||||
mod session_poller;
|
||||
pub mod settings;
|
||||
@@ -194,6 +195,15 @@ use commands::{
|
||||
playlist_move_item,
|
||||
playlist_remove_items,
|
||||
playlist_rename,
|
||||
profiles_add,
|
||||
profiles_get_ask_on_start,
|
||||
profiles_list,
|
||||
profiles_remove,
|
||||
profiles_set_ask_on_start,
|
||||
profiles_set_pin,
|
||||
profiles_startup_target,
|
||||
profiles_unlock,
|
||||
profiles_unlock_with_password,
|
||||
// Remote session control commands
|
||||
remote_play_on_session,
|
||||
remote_send_command,
|
||||
@@ -1036,6 +1046,15 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
playlist_rename,
|
||||
playlist_get_items,
|
||||
playlist_add_items,
|
||||
profiles_add,
|
||||
profiles_get_ask_on_start,
|
||||
profiles_list,
|
||||
profiles_remove,
|
||||
profiles_set_ask_on_start,
|
||||
profiles_set_pin,
|
||||
profiles_startup_target,
|
||||
profiles_unlock,
|
||||
profiles_unlock_with_password,
|
||||
playlist_remove_items,
|
||||
playlist_move_item,
|
||||
// Diagnostics commands
|
||||
|
||||
@@ -326,8 +326,12 @@ impl Span {
|
||||
///
|
||||
/// TRACES: UR-071 | DR-137 | UT-127
|
||||
pub fn span_for(range: Option<&str>, len: u64) -> Option<Span> {
|
||||
// A zero-length file has no byte to serve. `end` is inclusive, so the
|
||||
// shortest span this type can express is one byte — returning one for an
|
||||
// empty file declared `Content-Length: 1` and then streamed nothing, which
|
||||
// Chromium's media loader waits on forever. 416 says so honestly instead.
|
||||
if len == 0 {
|
||||
return Some(Span { start: 0, end: 0 });
|
||||
return None;
|
||||
}
|
||||
let last = len - 1;
|
||||
let first_chunk = Span {
|
||||
@@ -442,6 +446,42 @@ fn content_type(path: &Path, head: &[u8]) -> &'static str {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// An empty file must not be answered with a span that promises a byte.
|
||||
///
|
||||
/// `Span::len()` is `end + 1 - start`, so the `Span { start: 0, end: 0 }`
|
||||
/// that a zero-length file used to produce reported a length of **one**.
|
||||
/// The response then declared `Content-Length: 1` and streamed nothing,
|
||||
/// which Chromium's media loader waits on forever — reaching the user as a
|
||||
/// downloaded item that never starts. A zero-byte file has no satisfiable
|
||||
/// range, so 416 is the honest answer.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-137 | UT-127
|
||||
#[test]
|
||||
fn test_span_for_an_empty_file_is_unsatisfiable() {
|
||||
assert!(
|
||||
span_for(None, 0).is_none(),
|
||||
"a zero-length file has no byte to serve"
|
||||
);
|
||||
assert!(span_for(Some("bytes=0-"), 0).is_none());
|
||||
assert!(span_for(Some("bytes=0-100"), 0).is_none());
|
||||
}
|
||||
|
||||
/// Whatever a span says, its length must match the bytes that follow it.
|
||||
///
|
||||
/// TRACES: UR-071 | DR-137 | UT-127
|
||||
#[test]
|
||||
fn test_span_len_never_exceeds_the_file() {
|
||||
for len in [0u64, 1, 2, 4095, CHUNK_LEN, CHUNK_LEN + 1] {
|
||||
if let Some(span) = span_for(None, len) {
|
||||
assert!(
|
||||
span.len() <= len,
|
||||
"span for a {len}-byte file claims {} bytes",
|
||||
span.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole point: a request with no `Range` must still come back bounded.
|
||||
/// That is the case Tauri's asset protocol answers with the entire file —
|
||||
/// the read Chromium abandoned after 31s.
|
||||
|
||||
+379
-313
@@ -3,6 +3,7 @@
|
||||
//! This module provides a `PlayerBackend` implementation using Android's ExoPlayer
|
||||
//! through JNI calls to Kotlin code.
|
||||
|
||||
use super::jni_guard::jni_guard;
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use log::debug;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
@@ -677,42 +678,47 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
position: jdouble,
|
||||
duration: jdouble,
|
||||
) {
|
||||
// Debug: Log every 10th update to avoid spam
|
||||
static mut UPDATE_COUNTER: u32 = 0;
|
||||
unsafe {
|
||||
UPDATE_COUNTER += 1;
|
||||
if UPDATE_COUNTER % 10 == 0 {
|
||||
log::debug!("[Android] Position update {} / {}", position, duration);
|
||||
}
|
||||
}
|
||||
jni_guard(
|
||||
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPositionUpdate",
|
||||
|| {
|
||||
// Debug: Log every 10th update to avoid spam
|
||||
static mut UPDATE_COUNTER: u32 = 0;
|
||||
unsafe {
|
||||
UPDATE_COUNTER += 1;
|
||||
if UPDATE_COUNTER % 10 == 0 {
|
||||
log::debug!("[Android] Position update {} / {}", position, duration);
|
||||
}
|
||||
}
|
||||
|
||||
// Update state and get the preserved duration to emit
|
||||
let duration_to_emit = if let Some(state) = SHARED_STATE.get() {
|
||||
let mut state = state.lock_safe();
|
||||
state.position = position;
|
||||
if duration > 0.0 {
|
||||
state.duration = Some(duration);
|
||||
}
|
||||
// Use preserved duration from state, or fall back to the received value
|
||||
state.duration.unwrap_or(duration)
|
||||
} else {
|
||||
duration
|
||||
};
|
||||
// Update state and get the preserved duration to emit
|
||||
let duration_to_emit = if let Some(state) = SHARED_STATE.get() {
|
||||
let mut state = state.lock_safe();
|
||||
state.position = position;
|
||||
if duration > 0.0 {
|
||||
state.duration = Some(duration);
|
||||
}
|
||||
// Use preserved duration from state, or fall back to the received value
|
||||
state.duration.unwrap_or(duration)
|
||||
} else {
|
||||
duration
|
||||
};
|
||||
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PositionUpdate {
|
||||
position,
|
||||
duration: duration_to_emit,
|
||||
});
|
||||
} else {
|
||||
log::error!("[Android] WARNING: No event emitter for position update!");
|
||||
}
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PositionUpdate {
|
||||
position,
|
||||
duration: duration_to_emit,
|
||||
});
|
||||
} else {
|
||||
log::error!("[Android] WARNING: No event emitter for position update!");
|
||||
}
|
||||
|
||||
// Throttled progress reporting to Jellyfin so playback position syncs and can
|
||||
// be resumed on another device. ExoPlayer only fires position updates while
|
||||
// playing, but guard on the stored state anyway. Mirrors the MPV backend's
|
||||
// progress loop; both share the same EventThrottler (every 30s per item).
|
||||
report_android_progress(position);
|
||||
// Throttled progress reporting to Jellyfin so playback position syncs and can
|
||||
// be resumed on another device. ExoPlayer only fires position updates while
|
||||
// playing, but guard on the stored state anyway. Mirrors the MPV backend's
|
||||
// progress loop; both share the same EventThrottler (every 30s per item).
|
||||
report_android_progress(position);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Report throttled playback progress to Jellyfin from the Android position
|
||||
@@ -774,8 +780,17 @@ fn report_android_progress(position: f64) {
|
||||
handle.spawn(spawn_report());
|
||||
} else {
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(spawn_report());
|
||||
// Not `unwrap()`: this runs on a JNI thread, and `Runtime::new()`
|
||||
// fails under the fd exhaustion and thread-spawn refusal Android
|
||||
// subjects a media app to. The panic used to unwind out of the
|
||||
// `extern "system"` caller and abort the process — losing one
|
||||
// progress report is recoverable, losing the app is not.
|
||||
match tokio::runtime::Runtime::new() {
|
||||
Ok(rt) => rt.block_on(spawn_report()),
|
||||
Err(e) => log::error!(
|
||||
"[Android] No runtime available to report progress; dropping it: {e}"
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -790,51 +805,56 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
state: JString,
|
||||
media_id: JString,
|
||||
) {
|
||||
let state_str: String = env.get_string(&state).map(|s| s.into()).unwrap_or_default();
|
||||
jni_guard(
|
||||
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnStateChanged",
|
||||
|| {
|
||||
let state_str: String = env.get_string(&state).map(|s| s.into()).unwrap_or_default();
|
||||
|
||||
let media_id_opt: Option<String> = if media_id.is_null() {
|
||||
None
|
||||
} else {
|
||||
env.get_string(&media_id).map(|s| s.into()).ok()
|
||||
};
|
||||
let media_id_opt: Option<String> = if media_id.is_null() {
|
||||
None
|
||||
} else {
|
||||
env.get_string(&media_id).map(|s| s.into()).ok()
|
||||
};
|
||||
|
||||
// Update shared state
|
||||
if let Some(shared) = SHARED_STATE.get() {
|
||||
let mut shared = shared.lock_safe();
|
||||
if let Some(media) = shared.current_media.clone() {
|
||||
let duration = shared.duration.unwrap_or(0.0);
|
||||
let position = shared.position;
|
||||
match state_str.as_str() {
|
||||
"playing" => {
|
||||
shared.state = PlayerState::Playing {
|
||||
media,
|
||||
position,
|
||||
duration,
|
||||
};
|
||||
shared.is_loaded = true;
|
||||
// Update shared state
|
||||
if let Some(shared) = SHARED_STATE.get() {
|
||||
let mut shared = shared.lock_safe();
|
||||
if let Some(media) = shared.current_media.clone() {
|
||||
let duration = shared.duration.unwrap_or(0.0);
|
||||
let position = shared.position;
|
||||
match state_str.as_str() {
|
||||
"playing" => {
|
||||
shared.state = PlayerState::Playing {
|
||||
media,
|
||||
position,
|
||||
duration,
|
||||
};
|
||||
shared.is_loaded = true;
|
||||
}
|
||||
"paused" => {
|
||||
shared.state = PlayerState::Paused {
|
||||
media,
|
||||
position,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
"idle" => {
|
||||
shared.state = PlayerState::Idle;
|
||||
shared.is_loaded = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
"paused" => {
|
||||
shared.state = PlayerState::Paused {
|
||||
media,
|
||||
position,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
"idle" => {
|
||||
shared.state = PlayerState::Idle;
|
||||
shared.is_loaded = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::StateChanged {
|
||||
state: state_str,
|
||||
media_id: media_id_opt,
|
||||
});
|
||||
}
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::StateChanged {
|
||||
state: state_str,
|
||||
media_id: media_id_opt,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Called when media has finished loading.
|
||||
@@ -844,15 +864,20 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
_class: JClass,
|
||||
duration: jdouble,
|
||||
) {
|
||||
if let Some(state) = SHARED_STATE.get() {
|
||||
let mut state = state.lock_safe();
|
||||
state.duration = Some(duration);
|
||||
state.is_loaded = true;
|
||||
}
|
||||
jni_guard(
|
||||
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnMediaLoaded",
|
||||
|| {
|
||||
if let Some(state) = SHARED_STATE.get() {
|
||||
let mut state = state.lock_safe();
|
||||
state.duration = Some(duration);
|
||||
state.is_loaded = true;
|
||||
}
|
||||
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
|
||||
}
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Called when playback reaches the end.
|
||||
@@ -861,49 +886,38 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
) {
|
||||
log::info!("[ExoPlayer] Playback ended - processing autoplay decision");
|
||||
jni_guard(
|
||||
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPlaybackEnded",
|
||||
|| {
|
||||
log::info!("[ExoPlayer] Playback ended - processing autoplay decision");
|
||||
|
||||
// Get player controller and handle autoplay decision
|
||||
if let Some(controller) = PLAYER_CONTROLLER.get() {
|
||||
let controller = controller.clone();
|
||||
// Get player controller and handle autoplay decision
|
||||
if let Some(controller) = PLAYER_CONTROLLER.get() {
|
||||
let controller = controller.clone();
|
||||
|
||||
// Spawn async task to handle autoplay decision
|
||||
// Use tauri::async_runtime::spawn instead of tokio::spawn
|
||||
// JNI callbacks happen on arbitrary threads without a Tokio runtime
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Compute the autoplay decision and release the lock before matching.
|
||||
// Holding the guard across the match would deadlock the AdvanceToNext
|
||||
// arm, which re-locks the controller to call next() — leaving playback
|
||||
// stopped (paused at position 0) instead of advancing.
|
||||
let decision = controller.lock().await.on_playback_ended().await;
|
||||
match decision {
|
||||
Ok(AutoplayDecision::Stop) => {
|
||||
log::debug!("[Autoplay] Decision: Stop playback");
|
||||
// Emit PlaybackEnded event to frontend
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
}
|
||||
Ok(AutoplayDecision::AdvanceToNext) => {
|
||||
log::debug!("[Autoplay] Decision: Advance to next track");
|
||||
// Advance to next track in queue
|
||||
let ctrl = controller.lock().await;
|
||||
// Spawn async task to handle autoplay decision
|
||||
// Use tauri::async_runtime::spawn instead of tokio::spawn
|
||||
// JNI callbacks happen on arbitrary threads without a Tokio runtime
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Compute the autoplay decision and release the lock before matching.
|
||||
// Holding the guard across the match would deadlock the AdvanceToNext
|
||||
// arm, which re-locks the controller to call next() — leaving playback
|
||||
// stopped (paused at position 0) instead of advancing.
|
||||
let decision = controller.lock().await.on_playback_ended().await;
|
||||
match decision {
|
||||
Ok(AutoplayDecision::Stop) => {
|
||||
log::debug!("[Autoplay] Decision: Stop playback");
|
||||
// Emit PlaybackEnded event to frontend
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
}
|
||||
Ok(AutoplayDecision::AdvanceToNext) => {
|
||||
log::debug!("[Autoplay] Decision: Advance to next track");
|
||||
// Advance to next track in queue
|
||||
let ctrl = controller.lock().await;
|
||||
|
||||
// Log queue state before advancing
|
||||
let queue_info = {
|
||||
let queue = ctrl.queue.lock_safe();
|
||||
format!(
|
||||
"current_index={:?}, len={}",
|
||||
queue.current_index(),
|
||||
queue.items().len()
|
||||
)
|
||||
};
|
||||
log::debug!("[Autoplay] Queue state before next(): {}", queue_info);
|
||||
|
||||
match ctrl.next() {
|
||||
Ok(_) => {
|
||||
log::info!("[Autoplay] Successfully advanced to next track");
|
||||
// Log queue state after advancing
|
||||
// Log queue state before advancing
|
||||
let queue_info = {
|
||||
let queue = ctrl.queue.lock_safe();
|
||||
format!(
|
||||
@@ -912,93 +926,115 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
queue.items().len()
|
||||
)
|
||||
};
|
||||
log::debug!("[Autoplay] Queue state after next(): {}", queue_info);
|
||||
log::debug!("[Autoplay] Queue state before next(): {}", queue_info);
|
||||
|
||||
// Emit queue changed event so frontend updates UI with new current track
|
||||
ctrl.emit_queue_changed();
|
||||
match ctrl.next() {
|
||||
Ok(_) => {
|
||||
log::info!("[Autoplay] Successfully advanced to next track");
|
||||
// Log queue state after advancing
|
||||
let queue_info = {
|
||||
let queue = ctrl.queue.lock_safe();
|
||||
format!(
|
||||
"current_index={:?}, len={}",
|
||||
queue.current_index(),
|
||||
queue.items().len()
|
||||
)
|
||||
};
|
||||
log::debug!(
|
||||
"[Autoplay] Queue state after next(): {}",
|
||||
queue_info
|
||||
);
|
||||
|
||||
// Emit queue changed event so frontend updates UI with new current track
|
||||
ctrl.emit_queue_changed();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"[Autoplay] Failed to advance to next track: {}",
|
||||
e
|
||||
);
|
||||
// Emit PlaybackEnded event on error
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(AutoplayDecision::ShowNextEpisodePopup {
|
||||
current_episode,
|
||||
next_episode,
|
||||
countdown_seconds,
|
||||
auto_advance,
|
||||
}) => {
|
||||
log::info!(
|
||||
"[Autoplay] Decision: Show next episode popup (countdown: {}s, auto: {})",
|
||||
countdown_seconds,
|
||||
auto_advance
|
||||
);
|
||||
|
||||
// Emit popup event to frontend
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::ShowNextEpisodePopup {
|
||||
current_episode: current_episode.clone(),
|
||||
next_episode: next_episode.clone(),
|
||||
countdown_seconds,
|
||||
auto_advance,
|
||||
});
|
||||
}
|
||||
|
||||
if auto_advance {
|
||||
// Shared with the frontend-invoked command path
|
||||
// (player_on_playback_ended) so the two dispatchers cannot
|
||||
// disagree about how a background audio-only episode
|
||||
// advances — they did, and the command's copy was missing
|
||||
// the case entirely. That copy is the one that actually
|
||||
// decides here: the end reason set at load makes this
|
||||
// callback's own decision Stop, and the frontend echoes the
|
||||
// resulting PlaybackEnded back into the command.
|
||||
controller
|
||||
.lock()
|
||||
.await
|
||||
.auto_advance_to_next_episode(next_episode, countdown_seconds)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Ok(AutoplayDecision::ResumeStream { position }) => {
|
||||
// ExoPlayer reported ENDED because the progressive transcode's
|
||||
// connection dropped, not because the episode finished. This
|
||||
// is the arm that matters while backgrounded: it needs no
|
||||
// frontend echo, so the stream re-opens even with the webview
|
||||
// suspended — and playback never parks in STATE_ENDED, where
|
||||
// the next lockscreen/Bluetooth play restarts the item at 0:00.
|
||||
log::info!(
|
||||
"[Autoplay] Decision: Resume truncated stream at {:.1}s",
|
||||
position
|
||||
);
|
||||
let ctrl = controller.lock().await;
|
||||
if let Err(e) = ctrl.resume_stream_at(position).await {
|
||||
log::error!("[Autoplay] Failed to resume truncated stream: {}", e);
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[Autoplay] Failed to advance to next track: {}", e);
|
||||
log::error!("[Autoplay] Decision failed: {}", e);
|
||||
// Emit PlaybackEnded event on error
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(AutoplayDecision::ShowNextEpisodePopup {
|
||||
current_episode,
|
||||
next_episode,
|
||||
countdown_seconds,
|
||||
auto_advance,
|
||||
}) => {
|
||||
log::info!(
|
||||
"[Autoplay] Decision: Show next episode popup (countdown: {}s, auto: {})",
|
||||
countdown_seconds,
|
||||
auto_advance
|
||||
);
|
||||
|
||||
// Emit popup event to frontend
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::ShowNextEpisodePopup {
|
||||
current_episode: current_episode.clone(),
|
||||
next_episode: next_episode.clone(),
|
||||
countdown_seconds,
|
||||
auto_advance,
|
||||
});
|
||||
}
|
||||
|
||||
if auto_advance {
|
||||
// Shared with the frontend-invoked command path
|
||||
// (player_on_playback_ended) so the two dispatchers cannot
|
||||
// disagree about how a background audio-only episode
|
||||
// advances — they did, and the command's copy was missing
|
||||
// the case entirely. That copy is the one that actually
|
||||
// decides here: the end reason set at load makes this
|
||||
// callback's own decision Stop, and the frontend echoes the
|
||||
// resulting PlaybackEnded back into the command.
|
||||
controller
|
||||
.lock()
|
||||
.await
|
||||
.auto_advance_to_next_episode(next_episode, countdown_seconds)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Ok(AutoplayDecision::ResumeStream { position }) => {
|
||||
// ExoPlayer reported ENDED because the progressive transcode's
|
||||
// connection dropped, not because the episode finished. This
|
||||
// is the arm that matters while backgrounded: it needs no
|
||||
// frontend echo, so the stream re-opens even with the webview
|
||||
// suspended — and playback never parks in STATE_ENDED, where
|
||||
// the next lockscreen/Bluetooth play restarts the item at 0:00.
|
||||
log::info!(
|
||||
"[Autoplay] Decision: Resume truncated stream at {:.1}s",
|
||||
position
|
||||
);
|
||||
let ctrl = controller.lock().await;
|
||||
if let Err(e) = ctrl.resume_stream_at(position).await {
|
||||
log::error!("[Autoplay] Failed to resume truncated stream: {}", e);
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[Autoplay] Decision failed: {}", e);
|
||||
// Emit PlaybackEnded event on error
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
log::warn!("[Autoplay] PlayerController not initialized - emitting PlaybackEnded");
|
||||
// Fallback: just emit PlaybackEnded event
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
log::warn!("[Autoplay] PlayerController not initialized - emitting PlaybackEnded");
|
||||
// Fallback: just emit PlaybackEnded event
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Called when buffering state changes.
|
||||
@@ -1008,11 +1044,16 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
_class: JClass,
|
||||
percent: jint,
|
||||
) {
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::Buffering {
|
||||
percent: percent as u8,
|
||||
});
|
||||
}
|
||||
jni_guard(
|
||||
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnBuffering",
|
||||
|| {
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::Buffering {
|
||||
percent: percent as u8,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Called when a playback error occurs.
|
||||
@@ -1023,67 +1064,72 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
message: JString,
|
||||
recoverable: jboolean,
|
||||
) {
|
||||
let message_str: String = env
|
||||
.get_string(&message)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
let recoverable = recoverable != 0;
|
||||
jni_guard(
|
||||
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnError",
|
||||
|| {
|
||||
let message_str: String = env
|
||||
.get_string(&message)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
let recoverable = recoverable != 0;
|
||||
|
||||
// A background audio-only handoff is an mp3 the device was already decoding,
|
||||
// so a recoverable failure part-way through is the network. Surfacing it as a
|
||||
// player error stops playback for good (the frontend's handler calls
|
||||
// player_stop); re-opening the stream where it died is the "buffer and
|
||||
// resume" this actually is. Everything else keeps reporting the error.
|
||||
if recoverable {
|
||||
if let Some(controller) = PLAYER_CONTROLLER.get() {
|
||||
let controller = controller.clone();
|
||||
let message_str = message_str.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let resume = controller.lock().await.recoverable_error_resume();
|
||||
let Some((position, delay_secs)) = resume else {
|
||||
// Declined here, so report it as NOT recoverable: the frontend
|
||||
// would otherwise echo it into player_recover_stream and ask
|
||||
// the same question a second time.
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::Error {
|
||||
message: message_str,
|
||||
recoverable: false,
|
||||
});
|
||||
}
|
||||
// A background audio-only handoff is an mp3 the device was already decoding,
|
||||
// so a recoverable failure part-way through is the network. Surfacing it as a
|
||||
// player error stops playback for good (the frontend's handler calls
|
||||
// player_stop); re-opening the stream where it died is the "buffer and
|
||||
// resume" this actually is. Everything else keeps reporting the error.
|
||||
if recoverable {
|
||||
if let Some(controller) = PLAYER_CONTROLLER.get() {
|
||||
let controller = controller.clone();
|
||||
let message_str = message_str.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let resume = controller.lock().await.recoverable_error_resume();
|
||||
let Some((position, delay_secs)) = resume else {
|
||||
// Declined here, so report it as NOT recoverable: the frontend
|
||||
// would otherwise echo it into player_recover_stream and ask
|
||||
// the same question a second time.
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::Error {
|
||||
message: message_str,
|
||||
recoverable: false,
|
||||
});
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
log::warn!(
|
||||
"[ExoPlayer] Recoverable stream error ({}) — re-opening at {:.1}s in {}s",
|
||||
message_str,
|
||||
position,
|
||||
delay_secs
|
||||
);
|
||||
// Give a brief outage time to clear before asking the server for
|
||||
// the stream again; retrying instantly just burns the budget.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
|
||||
|
||||
let ctrl = controller.lock().await;
|
||||
if let Err(e) = ctrl.resume_stream_at(position).await {
|
||||
log::error!("[ExoPlayer] Failed to resume after error: {}", e);
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::Error {
|
||||
message: message_str,
|
||||
recoverable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
};
|
||||
|
||||
log::warn!(
|
||||
"[ExoPlayer] Recoverable stream error ({}) — re-opening at {:.1}s in {}s",
|
||||
message_str,
|
||||
position,
|
||||
delay_secs
|
||||
);
|
||||
// Give a brief outage time to clear before asking the server for
|
||||
// the stream again; retrying instantly just burns the budget.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
|
||||
|
||||
let ctrl = controller.lock().await;
|
||||
if let Err(e) = ctrl.resume_stream_at(position).await {
|
||||
log::error!("[ExoPlayer] Failed to resume after error: {}", e);
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::Error {
|
||||
message: message_str,
|
||||
recoverable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::Error {
|
||||
message: message_str,
|
||||
recoverable,
|
||||
});
|
||||
}
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::Error {
|
||||
message: message_str,
|
||||
recoverable,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Called when volume changes.
|
||||
@@ -1094,16 +1140,21 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
volume: jfloat,
|
||||
muted: jboolean,
|
||||
) {
|
||||
if let Some(state) = SHARED_STATE.get() {
|
||||
state.lock_safe().volume = volume;
|
||||
}
|
||||
jni_guard(
|
||||
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnVolumeChanged",
|
||||
|| {
|
||||
if let Some(state) = SHARED_STATE.get() {
|
||||
state.lock_safe().volume = volume;
|
||||
}
|
||||
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::VolumeChanged {
|
||||
volume,
|
||||
muted: muted != 0,
|
||||
});
|
||||
}
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::VolumeChanged {
|
||||
volume,
|
||||
muted: muted != 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// JNI callback for MediaSession commands from JellyTauPlaybackService
|
||||
@@ -1127,14 +1178,19 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackServic
|
||||
_class: JClass,
|
||||
command: JString,
|
||||
) {
|
||||
let command_str: String = env
|
||||
.get_string(&command)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_default();
|
||||
jni_guard(
|
||||
"Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnMediaCommand",
|
||||
|| {
|
||||
let command_str: String = env
|
||||
.get_string(&command)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Some(handler) = MEDIA_COMMAND_HANDLER.get() {
|
||||
handler.on_command(&command_str);
|
||||
}
|
||||
if let Some(handler) = MEDIA_COMMAND_HANDLER.get() {
|
||||
handler.on_command(&command_str);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// JNI callback from JellyTauPlaybackService when volume buttons are pressed in remote mode.
|
||||
@@ -1148,14 +1204,19 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackServic
|
||||
command: JString,
|
||||
volume: jint,
|
||||
) {
|
||||
let command_str: String = env
|
||||
.get_string(&command)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_default();
|
||||
jni_guard(
|
||||
"Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnRemoteVolumeChange",
|
||||
|| {
|
||||
let command_str: String = env
|
||||
.get_string(&command)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Some(handler) = REMOTE_VOLUME_HANDLER.get() {
|
||||
handler.on_remote_volume_change(&command_str, volume as i32);
|
||||
}
|
||||
if let Some(handler) = REMOTE_VOLUME_HANDLER.get() {
|
||||
handler.on_remote_volume_change(&command_str, volume as i32);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// JNI callback from Kotlin when codec detection completes.
|
||||
@@ -1170,40 +1231,45 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Co
|
||||
audio_codecs: JString,
|
||||
max_audio_channels: jint,
|
||||
) {
|
||||
let video_str: String = env
|
||||
.get_string(&video_codecs)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_default();
|
||||
jni_guard(
|
||||
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Companion_nativeOnCodecsDetected",
|
||||
|| {
|
||||
let video_str: String = env
|
||||
.get_string(&video_codecs)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_default();
|
||||
|
||||
let audio_str: String = env
|
||||
.get_string(&audio_codecs)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_default();
|
||||
let audio_str: String = env
|
||||
.get_string(&audio_codecs)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Kotlin sends 0 when AudioCapabilities had no answer for the current route.
|
||||
let channels = u32::try_from(max_audio_channels).ok().filter(|c| *c > 0);
|
||||
// Kotlin sends 0 when AudioCapabilities had no answer for the current route.
|
||||
let channels = u32::try_from(max_audio_channels).ok().filter(|c| *c > 0);
|
||||
|
||||
let codecs = DetectedCodecs::from_jni_strings(&video_str, &audio_str, channels);
|
||||
let codecs = DetectedCodecs::from_jni_strings(&video_str, &audio_str, channels);
|
||||
|
||||
log::info!(
|
||||
"[CodecDetection] Detected {} video codecs: {}",
|
||||
codecs.video_codecs.len(),
|
||||
codecs.video_codecs_string()
|
||||
log::info!(
|
||||
"[CodecDetection] Detected {} video codecs: {}",
|
||||
codecs.video_codecs.len(),
|
||||
codecs.video_codecs_string()
|
||||
);
|
||||
log::info!(
|
||||
"[CodecDetection] Detected {} audio codecs: {}",
|
||||
codecs.audio_codecs.len(),
|
||||
codecs.audio_codecs_string()
|
||||
);
|
||||
log::info!(
|
||||
"[CodecDetection] Audio route max channels: {:?}",
|
||||
codecs.max_audio_channels
|
||||
);
|
||||
|
||||
// Store in global state
|
||||
if DETECTED_CODECS.set(codecs).is_err() {
|
||||
log::error!("[CodecDetection] Failed to store codecs - already initialized");
|
||||
}
|
||||
},
|
||||
);
|
||||
log::info!(
|
||||
"[CodecDetection] Detected {} audio codecs: {}",
|
||||
codecs.audio_codecs.len(),
|
||||
codecs.audio_codecs_string()
|
||||
);
|
||||
log::info!(
|
||||
"[CodecDetection] Audio route max channels: {:?}",
|
||||
codecs.max_audio_channels
|
||||
);
|
||||
|
||||
// Store in global state
|
||||
if DETECTED_CODECS.set(codecs).is_err() {
|
||||
log::error!("[CodecDetection] Failed to store codecs - already initialized");
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the JellyTauPlaybackService if not already running.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Panic containment for the Android JNI boundary.
|
||||
//!
|
||||
//! Compiled on every platform, unlike `player::android` itself, so the guard and
|
||||
//! the tripwire that enforces its use are unit-tested on the host — the same
|
||||
//! reason `RESUME_BACKOFF_STEP_SECS` lives outside the `cfg(android)` block.
|
||||
|
||||
/// Run the body of a JNI callback with any panic contained.
|
||||
///
|
||||
/// Every `extern "system"` function in this file is called by the JVM on an
|
||||
/// arbitrary thread. A panic that unwinds out of one crosses the FFI boundary,
|
||||
/// which Rust answers by **aborting the process** — the app vanishes with no
|
||||
/// Java exception, no stack trace attributable to it, and no crash report the
|
||||
/// user can send. That is the worst possible failure mode for the callbacks
|
||||
/// that fire four times a second during playback.
|
||||
///
|
||||
/// The panics are real, not theoretical: this file builds a fallback Tokio
|
||||
/// runtime on threads that have none, and `Runtime::new()` fails under the fd
|
||||
/// exhaustion and thread-spawn refusal an Android device puts a media app
|
||||
/// through. Losing one position report is recoverable; losing the process is
|
||||
/// not.
|
||||
///
|
||||
/// A contained panic still leaves whatever it interrupted half-done, so this is
|
||||
/// a backstop, not a licence to panic. `utils::lock` already keeps a poisoned
|
||||
/// mutex from cascading; this keeps the FFI boundary from turning any remaining
|
||||
/// panic into a process kill.
|
||||
///
|
||||
/// TRACES: UR-005 | DR-052
|
||||
///
|
||||
/// Only *called* from `player::android`, which is `cfg(target_os = "android")`,
|
||||
/// so it is dead code on every other target — the same reason
|
||||
/// `RESUME_BACKOFF_STEP_SECS` carries this attribute. It is still compiled and
|
||||
/// tested here on purpose.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub(crate) fn jni_guard<F: FnOnce()>(name: &str, body: F) {
|
||||
// AssertUnwindSafe: the shared state behind these callbacks is already
|
||||
// reached through poison-tolerant locks, so a panic cannot hand out a
|
||||
// guard observing a torn value.
|
||||
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)).is_err() {
|
||||
// The panic hook has already logged the payload and location.
|
||||
log::error!("[JNI] Panic in {name} was contained; the callback was dropped");
|
||||
}
|
||||
}
|
||||
|
||||
// TRACES: UR-005 | DR-052 | UT-052
|
||||
#[cfg(test)]
|
||||
mod jni_guard_tests {
|
||||
use super::*;
|
||||
|
||||
/// The guard must swallow a panic rather than let it reach the JVM.
|
||||
///
|
||||
/// TRACES: UR-005 | DR-052 | UT-052
|
||||
#[test]
|
||||
fn a_panicking_callback_body_does_not_escape_the_guard() {
|
||||
let hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(|_| {}));
|
||||
jni_guard("test_callback", || panic!("ExoPlayer callback blew up"));
|
||||
std::panic::set_hook(hook);
|
||||
// Reaching here at all is the assertion: without the guard the panic
|
||||
// would unwind out of the `extern "system"` fn and abort the process.
|
||||
}
|
||||
|
||||
/// The guard must not disturb a callback that behaves.
|
||||
///
|
||||
/// TRACES: UR-005 | DR-052 | UT-052
|
||||
#[test]
|
||||
fn a_normal_callback_body_still_runs() {
|
||||
let mut ran = false;
|
||||
jni_guard("test_callback", || ran = true);
|
||||
assert!(ran);
|
||||
}
|
||||
|
||||
/// **Tripwire.** Every JNI entry point must wrap its body in `jni_guard`.
|
||||
///
|
||||
/// A panic crossing the `extern "system"` boundary aborts the process, so a
|
||||
/// twelfth callback added without the guard reintroduces the whole defect.
|
||||
/// Checked against the source because the real boundary needs a JVM to
|
||||
/// exercise — the same tripwire idiom as `check:boundary`.
|
||||
///
|
||||
/// TRACES: UR-005 | DR-052 | UT-052
|
||||
#[test]
|
||||
fn every_jni_entry_point_wraps_its_body_in_the_guard() {
|
||||
let src = include_str!("android/mod.rs");
|
||||
let mut unguarded = Vec::new();
|
||||
|
||||
let mut lines = src.lines().enumerate().peekable();
|
||||
while let Some((_, line)) = lines.next() {
|
||||
if !line.starts_with("pub extern \"system\" fn ") {
|
||||
continue;
|
||||
}
|
||||
let name = line
|
||||
.trim_start_matches("pub extern \"system\" fn ")
|
||||
.trim_end_matches('(')
|
||||
.to_string();
|
||||
|
||||
// Walk to the end of the parameter list, then look at the first
|
||||
// statement of the body.
|
||||
let mut body_start = None;
|
||||
for (n, l) in lines.by_ref() {
|
||||
if l.trim_end().ends_with(") {") || l.trim() == ") {" {
|
||||
body_start = Some(n);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(body_start.is_some(), "could not find the body of {name}");
|
||||
|
||||
match lines.peek() {
|
||||
Some((_, first)) if first.trim_start().starts_with("jni_guard(") => {}
|
||||
other => unguarded.push(format!(
|
||||
"{name} (body starts with {:?})",
|
||||
other.map(|(_, l)| l.trim()).unwrap_or("<eof>")
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
unguarded.is_empty(),
|
||||
"JNI entry points whose body is not wrapped in jni_guard — a panic in \
|
||||
one of these aborts the process:\n {}",
|
||||
unguarded.join("\n ")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
//! A declared lock hierarchy for [`PlayerController`], and a tripwire that
|
||||
//! enforces it.
|
||||
//!
|
||||
//! The controller carries seventeen separate mutexes, reached from the MPV event
|
||||
//! loop, JNI callbacks, sleep/autoplay timers, the session poller and every IPC
|
||||
//! command. Nothing about that arrangement prevents two threads taking the same
|
||||
//! two locks in opposite orders, which deadlocks the player outright — and this
|
||||
//! subsystem has already produced one deadlock (a tokio `MutexGuard` held in a
|
||||
//! `match` scrutinee, which stalled the `AdvanceToNext` arm).
|
||||
//!
|
||||
//! Today the code is disciplined: acquisitions are scoped, and `previous()` for
|
||||
//! instance explicitly drops the backend guard before touching the queue. But
|
||||
//! that holds by convention, and convention is not checked. [`LOCK_ORDER`]
|
||||
//! writes the convention down and `every_overlapping_acquisition_respects_the_order`
|
||||
//! fails the build when a change breaks it.
|
||||
//!
|
||||
//! Ordering only matters where one guard is **still held** while another lock is
|
||||
//! taken. Acquiring two locks one after another, each released before the next,
|
||||
//! cannot deadlock — so the analysis looks for overlap, not for mere sequence.
|
||||
//!
|
||||
//! TRACES: UR-005 | DR-052
|
||||
|
||||
// This module is a static analysis of `player/mod.rs` plus the hierarchy it
|
||||
// checks against. Its only caller is its own test module, but `LOCK_ORDER` is
|
||||
// the documentation of record for how these locks nest, so it stays compiled
|
||||
// (and rustdoc'd) rather than hidden behind `cfg(test)`.
|
||||
#![allow(dead_code)]
|
||||
|
||||
/// The order in which `PlayerController`'s locks may be nested.
|
||||
///
|
||||
/// A thread already holding one of these may only acquire a lock that appears
|
||||
/// **later** in this list. The order is not arbitrary — it follows the nesting
|
||||
/// the code already relies on:
|
||||
///
|
||||
/// - `repository` and `sleep_timer` are taken by long-running decisions that go
|
||||
/// on to consult playback state, so they sit outermost.
|
||||
/// - `backend` outranks `queue`: "what is playing" is read before "what is
|
||||
/// next", never the reverse.
|
||||
/// - `event_emitter` is last. Emitting is a leaf — notifying the frontend must
|
||||
/// never reach back for more player state.
|
||||
///
|
||||
/// TRACES: UR-005 | DR-052
|
||||
pub const LOCK_ORDER: &[&str] = &[
|
||||
"repository",
|
||||
"sleep_timer",
|
||||
"countdown_cancel",
|
||||
"jellyfin_client",
|
||||
"backend",
|
||||
"queue",
|
||||
"stream_resume",
|
||||
"end_reason",
|
||||
"reported_time",
|
||||
"background_audio_active",
|
||||
"background_audio_base",
|
||||
"html5_playing",
|
||||
"autoplay_settings",
|
||||
"autoplay_episode_count",
|
||||
"reports",
|
||||
"event_emitter",
|
||||
];
|
||||
|
||||
/// Rank of `field` in [`LOCK_ORDER`], or `None` if it is not a declared lock.
|
||||
pub fn rank(field: &str) -> Option<usize> {
|
||||
LOCK_ORDER.iter().position(|f| *f == field)
|
||||
}
|
||||
|
||||
/// One lock acquired while another is still held.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Overlap {
|
||||
/// The lock already held.
|
||||
pub outer: String,
|
||||
/// The lock acquired underneath it.
|
||||
pub inner: String,
|
||||
/// 1-indexed line of the inner acquisition, for a useful failure message.
|
||||
pub line: usize,
|
||||
}
|
||||
|
||||
/// Find every place `src` takes a lock while holding another.
|
||||
///
|
||||
/// Deliberately simple and line-based: it tracks `let … = self.FIELD.lock_safe()`
|
||||
/// bindings and looks for a different `self.OTHER.lock_safe()` before the
|
||||
/// binding goes out of scope or is explicitly dropped. A guard that is not bound
|
||||
/// to a name (`*self.flag.lock_safe() = false;`) is released at the end of its
|
||||
/// statement and cannot overlap anything, so it is only ever an *inner*
|
||||
/// acquisition here.
|
||||
///
|
||||
/// TRACES: UR-005 | DR-052 | UT-052
|
||||
pub fn overlapping_acquisitions(src: &str) -> Vec<Overlap> {
|
||||
let lines: Vec<&str> = src.lines().collect();
|
||||
let mut found = Vec::new();
|
||||
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let Some((var, field)) = parse_binding(line) else {
|
||||
continue;
|
||||
};
|
||||
let indent = line.len() - line.trim_start().len();
|
||||
|
||||
for (j, later) in lines.iter().enumerate().skip(i + 1) {
|
||||
if later.contains(&format!("drop({var})")) {
|
||||
break;
|
||||
}
|
||||
let trimmed = later.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Left the block the guard lives in.
|
||||
let later_indent = later.len() - later.trim_start().len();
|
||||
if later_indent < indent && !trimmed.starts_with(['.', ')', '}']) {
|
||||
break;
|
||||
}
|
||||
if trimmed == "}" && later_indent < indent {
|
||||
break;
|
||||
}
|
||||
if let Some(inner) = parse_acquisition(later, field) {
|
||||
found.push(Overlap {
|
||||
outer: field.to_string(),
|
||||
inner,
|
||||
line: j + 1,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// `let [mut] name = self.field.lock_safe()` → `(name, field)`.
|
||||
fn parse_binding(line: &str) -> Option<(&str, &str)> {
|
||||
let rest = line.trim_start().strip_prefix("let ")?;
|
||||
let rest = rest.strip_prefix("mut ").unwrap_or(rest);
|
||||
let (name, rest) = rest.split_once(" = self.")?;
|
||||
let (field, _) = rest.split_once(".lock_safe()")?;
|
||||
if name.contains(' ') || field.contains('.') {
|
||||
return None;
|
||||
}
|
||||
Some((name, field))
|
||||
}
|
||||
|
||||
/// The first `self.other.lock_safe()` on `line` that is not `held`.
|
||||
fn parse_acquisition(line: &str, held: &str) -> Option<String> {
|
||||
let mut search = line;
|
||||
while let Some(at) = search.find("self.") {
|
||||
let after = &search[at + 5..];
|
||||
if let Some((field, _)) = after.split_once(".lock_safe()") {
|
||||
if !field.contains(['.', '(', ' ']) && field != held {
|
||||
return Some(field.to_string());
|
||||
}
|
||||
}
|
||||
search = after;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// TRACES: UR-005 | DR-052 | UT-052
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// **The tripwire.** Every nested acquisition in the controller must follow
|
||||
/// [`LOCK_ORDER`].
|
||||
///
|
||||
/// A violation is a lock-order inversion: two threads taking the same pair
|
||||
/// in opposite orders deadlock the player, and the symptom is a frozen app
|
||||
/// with no error anywhere.
|
||||
///
|
||||
/// TRACES: UR-005 | DR-052 | UT-052
|
||||
#[test]
|
||||
fn every_overlapping_acquisition_respects_the_order() {
|
||||
let src = include_str!("mod.rs");
|
||||
let mut violations = Vec::new();
|
||||
|
||||
for overlap in overlapping_acquisitions(src) {
|
||||
let (Some(outer), Some(inner)) = (rank(&overlap.outer), rank(&overlap.inner)) else {
|
||||
violations.push(format!(
|
||||
"player/mod.rs:{} takes '{}' while holding '{}', and one of them \
|
||||
is not declared in LOCK_ORDER",
|
||||
overlap.line, overlap.inner, overlap.outer
|
||||
));
|
||||
continue;
|
||||
};
|
||||
if outer >= inner {
|
||||
violations.push(format!(
|
||||
"player/mod.rs:{} takes '{}' (rank {inner}) while holding '{}' \
|
||||
(rank {outer}) — an inversion against LOCK_ORDER",
|
||||
overlap.line, overlap.inner, overlap.outer
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
violations.is_empty(),
|
||||
"lock-order inversions in PlayerController:\n {}\n\nEither reorder the \
|
||||
acquisitions or, if the new order is the correct one, change LOCK_ORDER \
|
||||
and re-check every other site.",
|
||||
violations.join("\n ")
|
||||
);
|
||||
}
|
||||
|
||||
/// The analysis must actually see the nesting the controller does today,
|
||||
/// or the tripwire above passes by finding nothing.
|
||||
///
|
||||
/// TRACES: UR-005 | DR-052 | UT-052
|
||||
#[test]
|
||||
fn the_analysis_finds_the_nesting_that_exists() {
|
||||
let found = overlapping_acquisitions(include_str!("mod.rs"));
|
||||
assert!(
|
||||
found.len() >= 5,
|
||||
"expected the controller's known nested acquisitions, found {found:?}"
|
||||
);
|
||||
assert!(
|
||||
found
|
||||
.iter()
|
||||
.any(|o| o.outer == "backend" && o.inner == "queue"),
|
||||
"the backend->queue nesting in state() should be detected: {found:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A guard held across a lock taken in the wrong order must be caught.
|
||||
///
|
||||
/// TRACES: UR-005 | DR-052 | UT-052
|
||||
#[test]
|
||||
fn an_inversion_is_detected() {
|
||||
let src = " fn bad(&self) {\n\
|
||||
\x20 let queue = self.queue.lock_safe();\n\
|
||||
\x20 let b = self.backend.lock_safe();\n\
|
||||
\x20 }\n";
|
||||
let found = overlapping_acquisitions(src);
|
||||
assert_eq!(found.len(), 1, "{found:?}");
|
||||
assert_eq!(found[0].outer, "queue");
|
||||
assert_eq!(found[0].inner, "backend");
|
||||
assert!(rank("queue").unwrap() > rank("backend").unwrap());
|
||||
}
|
||||
|
||||
/// Sequential, non-overlapping acquisitions cannot deadlock and must not be
|
||||
/// reported — `previous()` drops the backend guard before taking the queue.
|
||||
///
|
||||
/// TRACES: UR-005 | DR-052 | UT-052
|
||||
#[test]
|
||||
fn a_dropped_guard_is_not_an_overlap() {
|
||||
let src = " fn fine(&self) {\n\
|
||||
\x20 let backend = self.backend.lock_safe();\n\
|
||||
\x20 drop(backend);\n\
|
||||
\x20 let queue = self.queue.lock_safe();\n\
|
||||
\x20 }\n";
|
||||
assert!(overlapping_acquisitions(src).is_empty());
|
||||
}
|
||||
|
||||
/// Every declared lock name must be a real field, or the order documents
|
||||
/// something that no longer exists.
|
||||
///
|
||||
/// TRACES: UR-005 | DR-052 | UT-052
|
||||
#[test]
|
||||
fn every_declared_lock_is_a_real_field() {
|
||||
let src = include_str!("mod.rs");
|
||||
let decl = src
|
||||
.split_once("pub struct PlayerController {")
|
||||
.expect("PlayerController struct")
|
||||
.1;
|
||||
let decl = decl.split_once("\n}").expect("end of struct").0;
|
||||
|
||||
for name in LOCK_ORDER {
|
||||
assert!(
|
||||
decl.contains(&format!("{name}:")),
|
||||
"LOCK_ORDER names '{name}', which is not a PlayerController field"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,14 @@ pub mod track_switch;
|
||||
#[cfg(test)]
|
||||
mod mpv_backend_test;
|
||||
|
||||
// The declared lock hierarchy for `PlayerController` below, and the tripwire
|
||||
// that enforces it. See the module docs for why seventeen mutexes need one.
|
||||
pub mod lock_order;
|
||||
|
||||
// Panic containment for the JNI boundary. Not gated on the target: the guard
|
||||
// and its tripwire test are exercised on the host, where `android` never builds.
|
||||
pub mod jni_guard;
|
||||
|
||||
// Platform-specific backends
|
||||
#[cfg(target_os = "android")]
|
||||
pub mod android;
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Multi-user profiles on one device.
|
||||
//!
|
||||
//! A "profile" is an account on the *currently connected* Jellyfin server that
|
||||
//! this device has signed into at least once. The rows have existed since the
|
||||
//! first schema (`users`), and so have `storage_get_users` /
|
||||
//! `storage_set_active_user`; what was missing was never the storage but the
|
||||
//! decision of who may switch to what — which is domain logic, and stays here.
|
||||
//!
|
||||
//! Two things this module is careful about:
|
||||
//!
|
||||
//! - **Switching is not logging out.** `auth_logout` calls Jellyfin's logout
|
||||
//! endpoint, which invalidates the token server-side. That is precisely the
|
||||
//! behaviour a switch must not have, or every switch back would need a
|
||||
//! password. Nothing here calls it.
|
||||
//! - **"Child account" is not modelled.** A child's profile is simply one with
|
||||
//! no PIN. The frontend receives an opaque [`UnlockMethod`] and renders it; it
|
||||
//! never infers a role, and no role taxonomy is invented on either side.
|
||||
//!
|
||||
//! TRACES: UR-082, UR-083, UR-084 | DR-267, DR-268
|
||||
|
||||
pub mod pin;
|
||||
pub mod store;
|
||||
pub mod switch;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// How a profile is entered.
|
||||
///
|
||||
/// Deliberately not "adult"/"child": the app has no way to know a person's age
|
||||
/// and no business encoding one. It knows whether a code is set.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-276
|
||||
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum UnlockMethod {
|
||||
/// One tap. No code set.
|
||||
None,
|
||||
/// A numeric code gates the switch.
|
||||
Pin,
|
||||
}
|
||||
|
||||
/// A switchable account on this device.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-267
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Profile {
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub server_id: String,
|
||||
/// Jellyfin's primary-image tag, for the tile. `None` renders initials.
|
||||
pub avatar_tag: Option<String>,
|
||||
pub unlock_method: UnlockMethod,
|
||||
pub last_used_at: Option<String>,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
/// The result of an unlock attempt.
|
||||
///
|
||||
/// Note the explicit field renames. tauri-specta emits tagged-union *fields*
|
||||
/// with their Rust names rather than camelCasing them, so a field that would
|
||||
/// differ between the two conventions is renamed here by hand — the same trap
|
||||
/// that produced `new_url` on the frontend once already.
|
||||
///
|
||||
/// TRACES: UR-083, UR-084 | DR-268, DR-269
|
||||
#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum UnlockOutcome {
|
||||
/// Switched. The profile is now active.
|
||||
Ok {
|
||||
#[serde(rename = "userId")]
|
||||
user_id: String,
|
||||
},
|
||||
/// Wrong code, attempts left.
|
||||
WrongPin {
|
||||
#[serde(rename = "attemptsRemaining")]
|
||||
attempts_remaining: u32,
|
||||
},
|
||||
/// Too many wrong codes; refused until this RFC3339 instant.
|
||||
LockedOut { until: String },
|
||||
/// No code is recoverable from here — sign in with the account password.
|
||||
NeedsPassword,
|
||||
}
|
||||
|
||||
/// What the app should do when it starts.
|
||||
///
|
||||
/// The decision is backend state (profile count, PIN presence, a stored
|
||||
/// setting), so the frontend asks rather than computes. A single account with no
|
||||
/// PIN always resumes, which is what keeps this feature invisible until it is
|
||||
/// wanted.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-274
|
||||
#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum StartupTarget {
|
||||
/// Resume this profile without asking.
|
||||
Resume {
|
||||
#[serde(rename = "userId")]
|
||||
user_id: String,
|
||||
},
|
||||
/// Show the picker.
|
||||
Picker,
|
||||
}
|
||||
|
||||
/// Decide the startup target from the profiles present and the user's setting.
|
||||
///
|
||||
/// Pure, because the rule is worth testing and the inputs are trivial to state:
|
||||
///
|
||||
/// - No profiles at all → picker (which renders as the first-run login).
|
||||
/// - The last-used profile has a PIN → picker, regardless of the setting. A code
|
||||
/// that could be skipped by relaunching is not a code.
|
||||
/// - More than one profile and "ask who's watching" is on → picker.
|
||||
/// - Otherwise → resume, exactly as the app behaved before profiles existed.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-274
|
||||
pub fn startup_target(profiles: &[Profile], ask_on_start: bool) -> StartupTarget {
|
||||
let last_used = profiles
|
||||
.iter()
|
||||
.max_by(|a, b| a.last_used_at.cmp(&b.last_used_at));
|
||||
|
||||
match last_used {
|
||||
None => StartupTarget::Picker,
|
||||
Some(p) if p.unlock_method == UnlockMethod::Pin => StartupTarget::Picker,
|
||||
Some(_) if ask_on_start && profiles.len() > 1 => StartupTarget::Picker,
|
||||
Some(p) => StartupTarget::Resume {
|
||||
user_id: p.user_id.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn profile(id: &str, unlock: UnlockMethod, last_used: Option<&str>) -> Profile {
|
||||
Profile {
|
||||
user_id: id.to_string(),
|
||||
username: id.to_string(),
|
||||
server_id: "server-1".to_string(),
|
||||
avatar_tag: None,
|
||||
unlock_method: unlock,
|
||||
last_used_at: last_used.map(|s| s.to_string()),
|
||||
is_active: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// UT: the pre-profiles install — one account, no PIN — never sees a picker.
|
||||
#[test]
|
||||
fn single_pinless_profile_resumes() {
|
||||
let profiles = vec![profile(
|
||||
"u1",
|
||||
UnlockMethod::None,
|
||||
Some("2026-01-01T00:00:00Z"),
|
||||
)];
|
||||
assert_eq!(
|
||||
startup_target(&profiles, true),
|
||||
StartupTarget::Resume {
|
||||
user_id: "u1".to_string()
|
||||
},
|
||||
"a lone profile resumes even with the setting on"
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: a PIN is not skippable by relaunching the app.
|
||||
#[test]
|
||||
fn pinned_last_profile_always_asks() {
|
||||
let profiles = vec![profile(
|
||||
"u1",
|
||||
UnlockMethod::Pin,
|
||||
Some("2026-01-01T00:00:00Z"),
|
||||
)];
|
||||
assert_eq!(startup_target(&profiles, false), StartupTarget::Picker);
|
||||
}
|
||||
|
||||
/// UT: several pinless profiles resume the last one unless asked to ask.
|
||||
#[test]
|
||||
fn multiple_profiles_follow_the_setting() {
|
||||
let profiles = vec![
|
||||
profile("u1", UnlockMethod::None, Some("2026-01-01T00:00:00Z")),
|
||||
profile("u2", UnlockMethod::None, Some("2026-02-01T00:00:00Z")),
|
||||
];
|
||||
assert_eq!(
|
||||
startup_target(&profiles, false),
|
||||
StartupTarget::Resume {
|
||||
user_id: "u2".to_string()
|
||||
},
|
||||
"resumes the most recently used"
|
||||
);
|
||||
assert_eq!(startup_target(&profiles, true), StartupTarget::Picker);
|
||||
}
|
||||
|
||||
/// UT: nothing signed in yet.
|
||||
#[test]
|
||||
fn no_profiles_shows_the_picker() {
|
||||
assert_eq!(startup_target(&[], false), StartupTarget::Picker);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Profile PIN: hashing, and the lockout policy that decides what a guess costs.
|
||||
//!
|
||||
//! The policy half is deliberately pure — it takes the stored counter state and
|
||||
//! the current time, and returns the decision plus the next state. That is what
|
||||
//! makes "five wrong guesses then a lockout that survives a restart" testable
|
||||
//! without a database, a clock, or a running app.
|
||||
//!
|
||||
//! What this is *not*: at-rest protection. The PIN gates switching to a profile;
|
||||
//! it does not encrypt that profile's access token, so anyone holding the
|
||||
//! database and the keyring has every token regardless. That trade is deliberate
|
||||
//! and its reasoning lives in DR-268 — a wrapped token would leave a locked
|
||||
//! profile unable to resume its own downloads or drain its own sync queue until
|
||||
//! somebody walked past and typed the code.
|
||||
//!
|
||||
//! TRACES: UR-083 | DR-268
|
||||
|
||||
use argon2::Argon2;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||
|
||||
/// Wrong guesses allowed before the first lockout.
|
||||
pub const MAX_ATTEMPTS: u32 = 5;
|
||||
|
||||
/// How long the first lockout lasts. Each subsequent failure doubles it.
|
||||
const BASE_LOCKOUT_SECS: i64 = 60;
|
||||
|
||||
/// Ceiling on the doubling, so a forgotten PIN never bricks the tile — the
|
||||
/// password route is always there, and a lockout measured in hours would push
|
||||
/// people towards not setting a PIN at all.
|
||||
const MAX_LOCKOUT_SECS: i64 = 15 * 60;
|
||||
|
||||
/// Persisted counter state for one profile's PIN.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PinState {
|
||||
pub failed_count: u32,
|
||||
pub locked_until: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl PinState {
|
||||
pub fn fresh() -> Self {
|
||||
Self {
|
||||
failed_count: 0,
|
||||
locked_until: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the caller should do with an attempt.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PinDecision {
|
||||
Accept,
|
||||
Reject { attempts_remaining: u32 },
|
||||
Locked { until: DateTime<Utc> },
|
||||
}
|
||||
|
||||
/// Decide an attempt and produce the state to persist.
|
||||
///
|
||||
/// `pin_matches` is the result of the hash comparison; passing it in rather than
|
||||
/// doing the comparison here is what keeps this function pure and cheap to test
|
||||
/// across the whole attempt/lockout space.
|
||||
///
|
||||
/// A locked profile is refused *without consulting the hash*, so a caller cannot
|
||||
/// burn through a lockout by guessing quickly.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
pub fn evaluate(
|
||||
state: &PinState,
|
||||
now: DateTime<Utc>,
|
||||
pin_matches: bool,
|
||||
) -> (PinDecision, PinState) {
|
||||
if let Some(until) = state.locked_until {
|
||||
if now < until {
|
||||
return (PinDecision::Locked { until }, state.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if pin_matches {
|
||||
return (PinDecision::Accept, PinState::fresh());
|
||||
}
|
||||
|
||||
let failed_count = state.failed_count.saturating_add(1);
|
||||
|
||||
if failed_count >= MAX_ATTEMPTS {
|
||||
let over = i64::from(failed_count - MAX_ATTEMPTS);
|
||||
let secs = BASE_LOCKOUT_SECS
|
||||
.saturating_mul(1i64.checked_shl(over.min(16) as u32).unwrap_or(i64::MAX))
|
||||
.min(MAX_LOCKOUT_SECS);
|
||||
let until = now + Duration::seconds(secs);
|
||||
(
|
||||
PinDecision::Locked { until },
|
||||
PinState {
|
||||
failed_count,
|
||||
locked_until: Some(until),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
(
|
||||
PinDecision::Reject {
|
||||
attempts_remaining: MAX_ATTEMPTS - failed_count,
|
||||
},
|
||||
PinState {
|
||||
failed_count,
|
||||
locked_until: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A PIN must be 4–8 digits. Rejecting non-digits here rather than in the pad
|
||||
/// keeps the rule where the rule is enforced.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
pub fn validate_pin(pin: &str) -> Result<(), String> {
|
||||
if pin.len() < 4 || pin.len() > 8 {
|
||||
return Err("PIN must be between 4 and 8 digits".to_string());
|
||||
}
|
||||
if !pin.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err("PIN must contain only digits".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hash a PIN for storage. Returns a PHC string with the salt embedded.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
pub fn hash_pin(pin: &str) -> Result<String, String> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
Argon2::default()
|
||||
.hash_password(pin.as_bytes(), &salt)
|
||||
.map(|h| h.to_string())
|
||||
.map_err(|e| format!("Failed to hash PIN: {}", e))
|
||||
}
|
||||
|
||||
/// Compare a candidate PIN against a stored PHC string.
|
||||
///
|
||||
/// A malformed stored hash verifies as `false` rather than erroring: a corrupt
|
||||
/// row should send the user down the password route, not wedge the picker.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
pub fn verify_pin(pin: &str, stored: &str) -> bool {
|
||||
match PasswordHash::new(stored) {
|
||||
Ok(parsed) => Argon2::default()
|
||||
.verify_password(pin.as_bytes(), &parsed)
|
||||
.is_ok(),
|
||||
Err(e) => {
|
||||
log::warn!("[Profiles] Stored PIN hash is unreadable: {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn t0() -> DateTime<Utc> {
|
||||
DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc)
|
||||
}
|
||||
|
||||
/// UT: a correct PIN is accepted and clears any accumulated failures.
|
||||
#[test]
|
||||
fn correct_pin_accepts_and_resets() {
|
||||
let state = PinState {
|
||||
failed_count: 3,
|
||||
locked_until: None,
|
||||
};
|
||||
let (decision, next) = evaluate(&state, t0(), true);
|
||||
assert_eq!(decision, PinDecision::Accept);
|
||||
assert_eq!(next, PinState::fresh());
|
||||
}
|
||||
|
||||
/// UT: wrong guesses count down, and the count is what gets persisted —
|
||||
/// this is the half that must survive an app restart.
|
||||
#[test]
|
||||
fn wrong_pin_counts_down() {
|
||||
let mut state = PinState::fresh();
|
||||
for expected in (1..MAX_ATTEMPTS).rev() {
|
||||
let (decision, next) = evaluate(&state, t0(), false);
|
||||
assert_eq!(
|
||||
decision,
|
||||
PinDecision::Reject {
|
||||
attempts_remaining: expected
|
||||
}
|
||||
);
|
||||
state = next;
|
||||
}
|
||||
assert_eq!(state.failed_count, MAX_ATTEMPTS - 1);
|
||||
}
|
||||
|
||||
/// UT: the attempt that exhausts the allowance locks out rather than
|
||||
/// reporting zero attempts remaining.
|
||||
#[test]
|
||||
fn exhausting_attempts_locks_out() {
|
||||
let state = PinState {
|
||||
failed_count: MAX_ATTEMPTS - 1,
|
||||
locked_until: None,
|
||||
};
|
||||
let (decision, next) = evaluate(&state, t0(), false);
|
||||
match decision {
|
||||
PinDecision::Locked { until } => {
|
||||
assert_eq!(until, t0() + Duration::seconds(BASE_LOCKOUT_SECS));
|
||||
}
|
||||
other => panic!("expected lockout, got {:?}", other),
|
||||
}
|
||||
assert_eq!(next.locked_until, Some(t0() + Duration::seconds(60)));
|
||||
}
|
||||
|
||||
/// UT: a locked profile is refused without the hash being consulted — the
|
||||
/// correct PIN does not shortcut an active lockout.
|
||||
#[test]
|
||||
fn lockout_refuses_even_a_correct_pin() {
|
||||
let until = t0() + Duration::seconds(60);
|
||||
let state = PinState {
|
||||
failed_count: MAX_ATTEMPTS,
|
||||
locked_until: Some(until),
|
||||
};
|
||||
let (decision, next) = evaluate(&state, t0(), true);
|
||||
assert_eq!(decision, PinDecision::Locked { until });
|
||||
assert_eq!(next, state, "a refused attempt must not extend the lockout");
|
||||
}
|
||||
|
||||
/// UT: once the window passes the profile accepts again.
|
||||
#[test]
|
||||
fn lockout_expires() {
|
||||
let until = t0() + Duration::seconds(60);
|
||||
let state = PinState {
|
||||
failed_count: MAX_ATTEMPTS,
|
||||
locked_until: Some(until),
|
||||
};
|
||||
let (decision, next) = evaluate(&state, until + Duration::seconds(1), true);
|
||||
assert_eq!(decision, PinDecision::Accept);
|
||||
assert_eq!(next, PinState::fresh());
|
||||
}
|
||||
|
||||
/// UT: repeated lockouts escalate, but stop at the ceiling so a forgotten
|
||||
/// PIN never becomes an hours-long wait.
|
||||
#[test]
|
||||
fn lockout_escalates_to_a_ceiling() {
|
||||
let mut seen = Vec::new();
|
||||
for failed in MAX_ATTEMPTS - 1..MAX_ATTEMPTS + 12 {
|
||||
let state = PinState {
|
||||
failed_count: failed,
|
||||
locked_until: None,
|
||||
};
|
||||
if let (PinDecision::Locked { until }, _) = evaluate(&state, t0(), false) {
|
||||
seen.push((until - t0()).num_seconds());
|
||||
}
|
||||
}
|
||||
assert_eq!(seen[0], BASE_LOCKOUT_SECS);
|
||||
assert!(seen[1] > seen[0], "second lockout should be longer");
|
||||
assert_eq!(*seen.last().unwrap(), MAX_LOCKOUT_SECS);
|
||||
assert!(seen.windows(2).all(|w| w[1] >= w[0]), "must not shrink");
|
||||
}
|
||||
|
||||
/// UT: hashing round-trips, and a wrong PIN does not verify.
|
||||
#[test]
|
||||
fn hash_round_trips() {
|
||||
let hash = hash_pin("1234").unwrap();
|
||||
assert!(verify_pin("1234", &hash));
|
||||
assert!(!verify_pin("4321", &hash));
|
||||
}
|
||||
|
||||
/// UT: the stored hash never contains the PIN itself.
|
||||
#[test]
|
||||
fn hash_does_not_leak_the_pin() {
|
||||
let hash = hash_pin("246813").unwrap();
|
||||
assert!(!hash.contains("246813"));
|
||||
}
|
||||
|
||||
/// UT: an unreadable stored hash fails closed instead of erroring, so a
|
||||
/// corrupt row sends the user to the password route.
|
||||
#[test]
|
||||
fn corrupt_hash_fails_closed() {
|
||||
assert!(!verify_pin("1234", "not-a-phc-string"));
|
||||
}
|
||||
|
||||
/// UT: PIN shape is enforced in Rust, not in the pad.
|
||||
#[test]
|
||||
fn pin_shape_is_validated() {
|
||||
assert!(validate_pin("1234").is_ok());
|
||||
assert!(validate_pin("12345678").is_ok());
|
||||
assert!(validate_pin("123").is_err(), "too short");
|
||||
assert!(validate_pin("123456789").is_err(), "too long");
|
||||
assert!(validate_pin("12a4").is_err(), "non-digit");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Database access for profiles.
|
||||
//!
|
||||
//! Everything here takes an explicit `user_id`. There is no ambient "current
|
||||
//! user" in this module — the caller has to say who it means, which is what
|
||||
//! stops a switch half-applying and writing one profile's state under another's
|
||||
//! id.
|
||||
//!
|
||||
//! TRACES: UR-082, UR-083 | DR-267, DR-268
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::pin::PinState;
|
||||
use super::{Profile, UnlockMethod};
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
|
||||
|
||||
/// List every profile known for a server, most recently used first.
|
||||
///
|
||||
/// A profile's unlock method is derived from the presence of a `user_pins` row
|
||||
/// rather than stored twice, so the two can never disagree.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-267
|
||||
pub async fn list_profiles(
|
||||
db: &Arc<RusqliteService>,
|
||||
server_id: &str,
|
||||
) -> Result<Vec<Profile>, String> {
|
||||
let query = Query::with_params(
|
||||
"SELECT u.id, u.username, u.server_id, u.is_active, u.last_login_at,
|
||||
CASE WHEN p.user_id IS NULL THEN 0 ELSE 1 END AS has_pin
|
||||
FROM users u
|
||||
LEFT JOIN user_pins p ON p.user_id = u.id
|
||||
WHERE u.server_id = ?
|
||||
ORDER BY u.last_login_at DESC",
|
||||
vec![QueryParam::String(server_id.to_string())],
|
||||
);
|
||||
|
||||
db.query_many(query, |row| {
|
||||
let has_pin: i32 = row.get(5)?;
|
||||
Ok(Profile {
|
||||
user_id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
server_id: row.get(2)?,
|
||||
avatar_tag: None,
|
||||
unlock_method: if has_pin != 0 {
|
||||
UnlockMethod::Pin
|
||||
} else {
|
||||
UnlockMethod::None
|
||||
},
|
||||
last_used_at: row.get(4)?,
|
||||
is_active: row.get::<_, i32>(3)? != 0,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Fetch a single profile, or `None` if this device does not know it.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-267
|
||||
pub async fn get_profile(
|
||||
db: &Arc<RusqliteService>,
|
||||
user_id: &str,
|
||||
) -> Result<Option<Profile>, String> {
|
||||
let query = Query::with_params(
|
||||
"SELECT u.id, u.username, u.server_id, u.is_active, u.last_login_at,
|
||||
CASE WHEN p.user_id IS NULL THEN 0 ELSE 1 END AS has_pin
|
||||
FROM users u
|
||||
LEFT JOIN user_pins p ON p.user_id = u.id
|
||||
WHERE u.id = ?",
|
||||
vec![QueryParam::String(user_id.to_string())],
|
||||
);
|
||||
|
||||
db.query_optional(query, |row| {
|
||||
let has_pin: i32 = row.get(5)?;
|
||||
Ok(Profile {
|
||||
user_id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
server_id: row.get(2)?,
|
||||
avatar_tag: None,
|
||||
unlock_method: if has_pin != 0 {
|
||||
UnlockMethod::Pin
|
||||
} else {
|
||||
UnlockMethod::None
|
||||
},
|
||||
last_used_at: row.get(4)?,
|
||||
is_active: row.get::<_, i32>(3)? != 0,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The stored PIN hash and attempt counters, or `None` when no PIN is set.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
pub async fn get_pin(
|
||||
db: &Arc<RusqliteService>,
|
||||
user_id: &str,
|
||||
) -> Result<Option<(String, PinState)>, String> {
|
||||
let query = Query::with_params(
|
||||
"SELECT pin_hash, failed_count, locked_until FROM user_pins WHERE user_id = ?",
|
||||
vec![QueryParam::String(user_id.to_string())],
|
||||
);
|
||||
|
||||
let row: Option<(String, i64, Option<String>)> = db
|
||||
.query_optional(query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(row.map(|(hash, failed, locked)| {
|
||||
let locked_until = locked
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc));
|
||||
(
|
||||
hash,
|
||||
PinState {
|
||||
failed_count: failed.max(0) as u32,
|
||||
locked_until,
|
||||
},
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
/// Store (or replace) a profile's PIN, resetting its counters.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
pub async fn set_pin(
|
||||
db: &Arc<RusqliteService>,
|
||||
user_id: &str,
|
||||
pin_hash: &str,
|
||||
) -> Result<(), String> {
|
||||
let query = Query::with_params(
|
||||
"INSERT INTO user_pins (user_id, pin_hash, failed_count, locked_until, updated_at)
|
||||
VALUES (?, ?, 0, NULL, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
pin_hash = excluded.pin_hash,
|
||||
failed_count = 0,
|
||||
locked_until = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP",
|
||||
vec![
|
||||
QueryParam::String(user_id.to_string()),
|
||||
QueryParam::String(pin_hash.to_string()),
|
||||
],
|
||||
);
|
||||
db.execute(query).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a profile's PIN, making it a one-tap profile.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
pub async fn clear_pin(db: &Arc<RusqliteService>, user_id: &str) -> Result<(), String> {
|
||||
let query = Query::with_params(
|
||||
"DELETE FROM user_pins WHERE user_id = ?",
|
||||
vec![QueryParam::String(user_id.to_string())],
|
||||
);
|
||||
db.execute(query).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist the counter state produced by [`super::pin::evaluate`].
|
||||
///
|
||||
/// This is what makes a lockout survive a restart: the deadline is on disk, not
|
||||
/// in a process-lifetime counter that closing the app would clear.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
pub async fn save_pin_state(
|
||||
db: &Arc<RusqliteService>,
|
||||
user_id: &str,
|
||||
state: &PinState,
|
||||
) -> Result<(), String> {
|
||||
let locked = match state.locked_until {
|
||||
Some(dt) => QueryParam::String(dt.to_rfc3339()),
|
||||
None => QueryParam::Null,
|
||||
};
|
||||
let query = Query::with_params(
|
||||
"UPDATE user_pins SET failed_count = ?, locked_until = ? WHERE user_id = ?",
|
||||
vec![
|
||||
QueryParam::Int64(i64::from(state.failed_count)),
|
||||
locked,
|
||||
QueryParam::String(user_id.to_string()),
|
||||
],
|
||||
);
|
||||
db.execute(query).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget a profile: its PIN, its per-user rows, and its `users` row.
|
||||
///
|
||||
/// Deliberately does **not** call Jellyfin's logout endpoint. Removing a profile
|
||||
/// from this device is a local act; invalidating a token the person may be using
|
||||
/// on their phone is not what "remove from this TV" means. The caller deletes the
|
||||
/// stored token separately.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-267
|
||||
pub async fn remove_profile(db: &Arc<RusqliteService>, user_id: &str) -> Result<(), String> {
|
||||
// ON DELETE CASCADE covers user_pins, user_data, user_item_visibility,
|
||||
// user_libraries, download_grants and the rest; the users row is the root.
|
||||
let query = Query::with_params(
|
||||
"DELETE FROM users WHERE id = ?",
|
||||
vec![QueryParam::String(user_id.to_string())],
|
||||
);
|
||||
db.execute(query).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Profile switch orchestration, as a plan rather than a procedure.
|
||||
//!
|
||||
//! Switching profiles tears down and rebuilds nearly everything the app holds:
|
||||
//! the player and its queue, the sync queue drain, the session poller, the
|
||||
//! lockscreen metadata, the repository handle. The *ordering* of that teardown
|
||||
//! is a correctness invariant, not an implementation detail — a straggler that
|
||||
//! reports after the active user has flipped attributes one account's viewing to
|
||||
//! another, which is silent, plausible-looking, and unrecoverable.
|
||||
//!
|
||||
//! So the ordering lives here as a pure function returning a list of steps, and
|
||||
//! the command layer executes them. That is the only way this gets tested: an
|
||||
//! end-to-end switch needs two real accounts on a real server, which CI does not
|
||||
//! have and never will. The plan needs nothing.
|
||||
//!
|
||||
//! Two hazards worth remembering while executing a plan, both already paid for
|
||||
//! elsewhere in this codebase (see CLAUDE.md): never call a blocking API from a
|
||||
//! player event callback, and never hold a lock across a `match` scrutinee. A
|
||||
//! teardown reaches every one of those paths at once, from a new direction.
|
||||
//!
|
||||
//! TRACES: UR-082 | DR-270
|
||||
|
||||
/// One executable step of a switch.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-270
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SwitchStep {
|
||||
/// Stop playback and drop the queue. The queue cannot outlive its owner.
|
||||
StopPlayback,
|
||||
/// Flush what the outgoing profile changed while offline, so it is not
|
||||
/// replayed under the incoming profile's token.
|
||||
ParkSyncQueue {
|
||||
user_id: String,
|
||||
},
|
||||
StopSessionPoller,
|
||||
/// Clear OS media metadata so the lockscreen does not show the outgoing
|
||||
/// profile's episode to whoever just took over the device.
|
||||
ClearLockscreenMetadata,
|
||||
DestroyRepository,
|
||||
/// The point of no return: after this, writes land under the new profile.
|
||||
SetActiveUser {
|
||||
user_id: String,
|
||||
},
|
||||
BuildRepository {
|
||||
user_id: String,
|
||||
},
|
||||
StartSessionPoller,
|
||||
/// Re-derive what the server currently lets this profile see. Only possible
|
||||
/// online; offline the cached view stays as it was, which is stale-permissive
|
||||
/// by design.
|
||||
RefreshVisibility {
|
||||
user_id: String,
|
||||
},
|
||||
EmitSwitched {
|
||||
user_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Build the ordered plan for moving from `from` to `to`.
|
||||
///
|
||||
/// Switching to the profile that is already active is not a no-op — it is how an
|
||||
/// idle re-lock is dismissed — but it must not tear down playback, or unlocking
|
||||
/// your own screen would stop the music. Only the emit survives.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-270
|
||||
pub fn plan(from: Option<&str>, to: &str, online: bool) -> Vec<SwitchStep> {
|
||||
if from == Some(to) {
|
||||
return vec![SwitchStep::EmitSwitched {
|
||||
user_id: to.to_string(),
|
||||
}];
|
||||
}
|
||||
|
||||
let mut steps = Vec::new();
|
||||
|
||||
if let Some(outgoing) = from {
|
||||
steps.push(SwitchStep::StopPlayback);
|
||||
steps.push(SwitchStep::ParkSyncQueue {
|
||||
user_id: outgoing.to_string(),
|
||||
});
|
||||
steps.push(SwitchStep::StopSessionPoller);
|
||||
steps.push(SwitchStep::ClearLockscreenMetadata);
|
||||
steps.push(SwitchStep::DestroyRepository);
|
||||
}
|
||||
|
||||
steps.push(SwitchStep::SetActiveUser {
|
||||
user_id: to.to_string(),
|
||||
});
|
||||
steps.push(SwitchStep::BuildRepository {
|
||||
user_id: to.to_string(),
|
||||
});
|
||||
steps.push(SwitchStep::StartSessionPoller);
|
||||
|
||||
if online {
|
||||
steps.push(SwitchStep::RefreshVisibility {
|
||||
user_id: to.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
steps.push(SwitchStep::EmitSwitched {
|
||||
user_id: to.to_string(),
|
||||
});
|
||||
|
||||
steps
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn index_of(steps: &[SwitchStep], want: &SwitchStep) -> usize {
|
||||
steps
|
||||
.iter()
|
||||
.position(|s| s == want)
|
||||
.unwrap_or_else(|| panic!("step {:?} missing from plan {:?}", want, steps))
|
||||
}
|
||||
|
||||
/// UT: the invariant that prevents misattributed playback reports —
|
||||
/// everything belonging to the outgoing profile is torn down *before* the
|
||||
/// active user flips.
|
||||
#[test]
|
||||
fn teardown_precedes_the_flip() {
|
||||
let steps = plan(Some("dad"), "kid", true);
|
||||
let flip = index_of(
|
||||
&steps,
|
||||
&SwitchStep::SetActiveUser {
|
||||
user_id: "kid".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(index_of(&steps, &SwitchStep::StopPlayback) < flip);
|
||||
assert!(
|
||||
index_of(
|
||||
&steps,
|
||||
&SwitchStep::ParkSyncQueue {
|
||||
user_id: "dad".to_string()
|
||||
}
|
||||
) < flip
|
||||
);
|
||||
assert!(index_of(&steps, &SwitchStep::StopSessionPoller) < flip);
|
||||
assert!(index_of(&steps, &SwitchStep::DestroyRepository) < flip);
|
||||
}
|
||||
|
||||
/// UT: the outgoing profile's queued offline mutations are parked under
|
||||
/// *its* id, never the incoming one's.
|
||||
#[test]
|
||||
fn sync_queue_is_parked_for_the_outgoing_profile() {
|
||||
let steps = plan(Some("dad"), "kid", true);
|
||||
assert!(steps.contains(&SwitchStep::ParkSyncQueue {
|
||||
user_id: "dad".to_string()
|
||||
}));
|
||||
assert!(!steps.contains(&SwitchStep::ParkSyncQueue {
|
||||
user_id: "kid".to_string()
|
||||
}));
|
||||
}
|
||||
|
||||
/// UT: the repository is rebuilt only after the flip, so it cannot be
|
||||
/// constructed against a user id that is about to change.
|
||||
#[test]
|
||||
fn repository_is_rebuilt_after_the_flip() {
|
||||
let steps = plan(Some("dad"), "kid", true);
|
||||
let flip = index_of(
|
||||
&steps,
|
||||
&SwitchStep::SetActiveUser {
|
||||
user_id: "kid".to_string(),
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
index_of(
|
||||
&steps,
|
||||
&SwitchStep::BuildRepository {
|
||||
user_id: "kid".to_string()
|
||||
}
|
||||
) > flip
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: the switch is announced last, so nothing observing the event can
|
||||
/// catch the app mid-teardown.
|
||||
#[test]
|
||||
fn switch_is_announced_last() {
|
||||
let steps = plan(Some("dad"), "kid", true);
|
||||
assert_eq!(
|
||||
steps.last(),
|
||||
Some(&SwitchStep::EmitSwitched {
|
||||
user_id: "kid".to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: first sign-in has nothing to tear down.
|
||||
#[test]
|
||||
fn cold_start_only_builds_up() {
|
||||
let steps = plan(None, "kid", true);
|
||||
assert!(!steps.contains(&SwitchStep::StopPlayback));
|
||||
assert!(!steps.contains(&SwitchStep::DestroyRepository));
|
||||
assert_eq!(
|
||||
steps.first(),
|
||||
Some(&SwitchStep::SetActiveUser {
|
||||
user_id: "kid".to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: offline, visibility cannot be re-derived — the server is not there to
|
||||
/// say what this profile may see, and guessing would be worse than stale.
|
||||
#[test]
|
||||
fn offline_skips_visibility_refresh() {
|
||||
let steps = plan(Some("dad"), "kid", false);
|
||||
assert!(!steps
|
||||
.iter()
|
||||
.any(|s| matches!(s, SwitchStep::RefreshVisibility { .. })));
|
||||
}
|
||||
|
||||
/// UT: dismissing an idle re-lock on your own profile must not stop the
|
||||
/// music you were listening to.
|
||||
#[test]
|
||||
fn unlocking_the_same_profile_does_not_disturb_playback() {
|
||||
let steps = plan(Some("dad"), "dad", true);
|
||||
assert_eq!(
|
||||
steps,
|
||||
vec![SwitchStep::EmitSwitched {
|
||||
user_id: "dad".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,38 @@ use tokio::time::{timeout, Duration};
|
||||
use super::exclusions::ExcludeHidden;
|
||||
use super::{types::*, MediaRepository, OfflineRepository, OnlineRepository};
|
||||
|
||||
/// The cache side of a cache-first query.
|
||||
///
|
||||
/// Either the cache answered inside the fast path, or it is still working and
|
||||
/// the query can be collected later. Keeping the slow case *addressable* rather
|
||||
/// than discarding it is what lets an offline query fall back to cached content
|
||||
/// after the server leg fails.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-013
|
||||
enum CacheLeg<T> {
|
||||
/// The cache answered within [`HybridRepository::CACHE_FAST_PATH`].
|
||||
Ready(Result<T, RepoError>),
|
||||
/// Still running. Awaiting the handle yields the answer eventually.
|
||||
Slow(tokio::task::JoinHandle<Result<T, RepoError>>),
|
||||
}
|
||||
|
||||
impl<T> CacheLeg<T> {
|
||||
/// Split into the fast-path answer and the still-running query. Exactly one
|
||||
/// side is `Some`.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn split(
|
||||
self,
|
||||
) -> (
|
||||
Option<Result<T, RepoError>>,
|
||||
Option<tokio::task::JoinHandle<Result<T, RepoError>>>,
|
||||
) {
|
||||
match self {
|
||||
CacheLeg::Ready(result) => (Some(result), None),
|
||||
CacheLeg::Slow(handle) => (None, Some(handle)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hybrid repository combining online and offline data sources
|
||||
///
|
||||
/// Uses cache-first parallel racing strategy:
|
||||
@@ -379,35 +411,12 @@ impl HybridRepository {
|
||||
/// @req: DR-013 - Repository pattern for online/offline data access
|
||||
///
|
||||
/// TRACES: UR-002, UR-076 | DR-013, DR-209
|
||||
async fn parallel_race<T, F1, F2>(
|
||||
&self,
|
||||
cache_future: F1,
|
||||
server_future: F2,
|
||||
) -> Result<T, RepoError>
|
||||
async fn parallel_race<T, F2>(cache: CacheLeg<T>, server_future: F2) -> Result<T, RepoError>
|
||||
where
|
||||
T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static,
|
||||
F1: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
F2: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
{
|
||||
// Try cache first (100ms timeout already applied by callers)
|
||||
let cache_result = cache_future.await.map(ExcludeHidden::without_excluded);
|
||||
|
||||
if let Ok(data) = &cache_result {
|
||||
if data.has_content() {
|
||||
debug!("[HybridRepo] Cache hit, returning immediately");
|
||||
return Ok(data.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss — fall back to server
|
||||
debug!("[HybridRepo] Cache miss, querying server");
|
||||
match server_future.await {
|
||||
Ok(data) => Ok(data.without_excluded()),
|
||||
Err(e) => {
|
||||
// Server failed, try to return cache even if empty
|
||||
cache_result.or(Err(e))
|
||||
}
|
||||
}
|
||||
Self::race_with_refresh(cache, server_future, || {}).await
|
||||
}
|
||||
|
||||
/// [`Self::parallel_race`], plus a callback fired on the fast path so the
|
||||
@@ -424,21 +433,20 @@ impl HybridRepository {
|
||||
/// already being fetched and cached by the normal path.
|
||||
///
|
||||
/// TRACES: UR-002, UR-025, UR-076 | DR-155, DR-209
|
||||
async fn race_with_refresh<T, F1, F2, R>(
|
||||
&self,
|
||||
cache_future: F1,
|
||||
async fn race_with_refresh<T, F2, R>(
|
||||
cache: CacheLeg<T>,
|
||||
server_future: F2,
|
||||
on_cache_hit: R,
|
||||
) -> Result<T, RepoError>
|
||||
where
|
||||
T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static,
|
||||
F1: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
F2: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
R: FnOnce(),
|
||||
{
|
||||
let cache_result = cache_future.await.map(ExcludeHidden::without_excluded);
|
||||
let (fast, slow) = cache.split();
|
||||
let fast = fast.map(|r| r.map(ExcludeHidden::without_excluded));
|
||||
|
||||
if let Ok(data) = &cache_result {
|
||||
if let Some(Ok(data)) = &fast {
|
||||
if data.has_content() {
|
||||
debug!("[HybridRepo] Cache hit, returning immediately (refreshing in background)");
|
||||
on_cache_hit();
|
||||
@@ -446,21 +454,81 @@ impl HybridRepository {
|
||||
}
|
||||
}
|
||||
|
||||
debug!("[HybridRepo] Cache miss, querying server");
|
||||
debug!("[HybridRepo] Cache miss or slow, querying server");
|
||||
match server_future.await {
|
||||
Ok(data) => Ok(data.without_excluded()),
|
||||
Err(e) => cache_result.or(Err(e)),
|
||||
Err(e) => {
|
||||
// The server cannot answer. If the cache is still working, it is
|
||||
// now the only thing that can, so wait it out rather than
|
||||
// reporting the server's failure over data we are about to hold.
|
||||
// This is the offline path: a cache read slowed by a concurrent
|
||||
// write used to surface as a network error.
|
||||
if let Some(handle) = slow {
|
||||
debug!("[HybridRepo] Server failed; waiting for the slow cache query");
|
||||
return match handle.await {
|
||||
Ok(Ok(data)) => Ok(data.without_excluded()),
|
||||
Ok(Err(cache_err)) => {
|
||||
debug!("[HybridRepo] Slow cache query also failed: {cache_err}");
|
||||
Err(e)
|
||||
}
|
||||
Err(join) => {
|
||||
debug!("[HybridRepo] Slow cache query panicked: {join}");
|
||||
Err(e)
|
||||
}
|
||||
};
|
||||
}
|
||||
// Cache answered in time but had nothing: return that, so an
|
||||
// empty-but-valid cached listing still beats a network error.
|
||||
fast.unwrap_or(Err(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple timeout wrapper for cache queries (100ms timeout)
|
||||
/// How long the cache gets to answer before a query falls through to the
|
||||
/// server. Short on purpose: this bounds how long a *cache hit* may delay
|
||||
/// the UI, not how long the query is allowed to take.
|
||||
const CACHE_FAST_PATH: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Start a cache query and give it [`Self::CACHE_FAST_PATH`] to answer.
|
||||
///
|
||||
/// @req: DR-013 - Repository pattern (cache-first with timeout)
|
||||
/// Missing the deadline does **not** cancel the query — it keeps running on
|
||||
/// its own task and [`CacheLeg::settle`] can still collect it. That
|
||||
/// distinction is the whole point. The database is one SQLite connection
|
||||
/// behind one mutex, so a concurrent write (a sync drain, a bulk
|
||||
/// `save_to_cache`) blocks reads for its duration and this deadline trips
|
||||
/// routinely on slow storage. Treating that as "the cache is empty" while
|
||||
/// throwing the answer away meant that offline — where the server leg also
|
||||
/// fails — browsing surfaced a network error instead of the cached content
|
||||
/// sitting right there on disk.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-013
|
||||
async fn cache_leg<T>(
|
||||
future: impl std::future::Future<Output = Result<T, RepoError>> + Send + 'static,
|
||||
) -> CacheLeg<T>
|
||||
where
|
||||
T: Send + 'static,
|
||||
{
|
||||
// `&mut handle` so the timeout borrows the join handle rather than
|
||||
// consuming it: on expiry the task is still ours to collect.
|
||||
let mut handle = tokio::spawn(future);
|
||||
match timeout(Self::CACHE_FAST_PATH, &mut handle).await {
|
||||
Ok(Ok(result)) => CacheLeg::Ready(result),
|
||||
Ok(Err(join)) => CacheLeg::Ready(Err(RepoError::Database {
|
||||
message: format!("Cache query failed: {join}"),
|
||||
})),
|
||||
Err(_) => {
|
||||
debug!("[HybridRepo] Cache missed the fast path; leaving it running");
|
||||
CacheLeg::Slow(handle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Await a cache query that is still running, however long it takes.
|
||||
async fn cache_with_timeout<T>(
|
||||
&self,
|
||||
future: impl std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
) -> Result<T, RepoError> {
|
||||
timeout(Duration::from_millis(100), future)
|
||||
timeout(Self::CACHE_FAST_PATH, future)
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
Err(RepoError::Database {
|
||||
@@ -657,7 +725,7 @@ impl MediaRepository for HybridRepository {
|
||||
let item_id = item_id.to_string();
|
||||
let item_id_clone = item_id.clone();
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move { offline.get_item(&item_id).await });
|
||||
let cache_future = Self::cache_leg(async move { offline.get_item(&item_id).await }).await;
|
||||
|
||||
let online_for_refresh = Arc::clone(&self.online);
|
||||
let offline_for_save = Arc::clone(&self.offline);
|
||||
@@ -683,8 +751,7 @@ impl MediaRepository for HybridRepository {
|
||||
|
||||
let server_future = async move { online.get_item(&item_id_clone).await };
|
||||
|
||||
self.race_with_refresh(cache_future, server_future, on_cache_hit)
|
||||
.await
|
||||
Self::race_with_refresh(cache_future, server_future, on_cache_hit).await
|
||||
}
|
||||
|
||||
async fn get_latest_items(
|
||||
@@ -698,13 +765,13 @@ impl MediaRepository for HybridRepository {
|
||||
let parent_id_clone = parent_id.clone();
|
||||
let limit_clone = limit;
|
||||
|
||||
let cache_future = self
|
||||
.cache_with_timeout(async move { offline.get_latest_items(&parent_id, limit).await });
|
||||
let cache_future =
|
||||
Self::cache_leg(async move { offline.get_latest_items(&parent_id, limit).await }).await;
|
||||
|
||||
let server_future =
|
||||
async move { online.get_latest_items(&parent_id_clone, limit_clone).await };
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
Self::parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_resume_items(
|
||||
@@ -718,11 +785,12 @@ impl MediaRepository for HybridRepository {
|
||||
let parent_id_clone = parent_id_str.clone();
|
||||
let limit_clone = limit;
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
let cache_future = Self::cache_leg(async move {
|
||||
offline
|
||||
.get_resume_items(parent_id_str.as_deref(), limit)
|
||||
.await
|
||||
});
|
||||
})
|
||||
.await;
|
||||
|
||||
let server_future = async move {
|
||||
online
|
||||
@@ -730,7 +798,7 @@ impl MediaRepository for HybridRepository {
|
||||
.await
|
||||
};
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
Self::parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_next_up_episodes(
|
||||
@@ -754,11 +822,11 @@ impl MediaRepository for HybridRepository {
|
||||
let limit_clone = limit;
|
||||
|
||||
let cache_future =
|
||||
self.cache_with_timeout(async move { offline.get_recently_played_audio(limit).await });
|
||||
Self::cache_leg(async move { offline.get_recently_played_audio(limit).await }).await;
|
||||
|
||||
let server_future = async move { online.get_recently_played_audio(limit_clone).await };
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
Self::parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
@@ -767,11 +835,11 @@ impl MediaRepository for HybridRepository {
|
||||
let limit_clone = limit;
|
||||
|
||||
let cache_future =
|
||||
self.cache_with_timeout(async move { offline.get_resume_movies(limit).await });
|
||||
Self::cache_leg(async move { offline.get_resume_movies(limit).await }).await;
|
||||
|
||||
let server_future = async move { online.get_resume_movies(limit_clone).await };
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
Self::parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_rediscover_albums(
|
||||
@@ -784,11 +852,12 @@ impl MediaRepository for HybridRepository {
|
||||
let parent_id_owned = parent_id.map(|s| s.to_string());
|
||||
let parent_id_clone = parent_id_owned.clone();
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
let cache_future = Self::cache_leg(async move {
|
||||
offline
|
||||
.get_rediscover_albums(parent_id_owned.as_deref(), limit)
|
||||
.await
|
||||
});
|
||||
})
|
||||
.await;
|
||||
|
||||
let server_future = async move {
|
||||
online
|
||||
@@ -796,7 +865,7 @@ impl MediaRepository for HybridRepository {
|
||||
.await
|
||||
};
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
Self::parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
||||
@@ -869,11 +938,11 @@ impl MediaRepository for HybridRepository {
|
||||
let opts_clone = options.clone();
|
||||
|
||||
let cache_future =
|
||||
self.cache_with_timeout(async move { offline.search(&query, opts_clone).await });
|
||||
Self::cache_leg(async move { offline.search(&query, opts_clone).await }).await;
|
||||
|
||||
let server_future = async move { online.search(&query_clone, options).await };
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
Self::parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
|
||||
@@ -1019,11 +1088,11 @@ impl MediaRepository for HybridRepository {
|
||||
let person_id_clone = person_id.clone();
|
||||
|
||||
let cache_future =
|
||||
self.cache_with_timeout(async move { offline.get_person(&person_id).await });
|
||||
Self::cache_leg(async move { offline.get_person(&person_id).await }).await;
|
||||
|
||||
let server_future = async move { online.get_person(&person_id_clone).await };
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
Self::parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_items_by_person(
|
||||
@@ -1037,14 +1106,16 @@ impl MediaRepository for HybridRepository {
|
||||
let person_id_clone = person_id.clone();
|
||||
let opts_clone = options.clone();
|
||||
|
||||
let cache_future = self.cache_with_timeout(async move {
|
||||
offline.get_items_by_person(&person_id, opts_clone).await
|
||||
});
|
||||
let cache_future =
|
||||
Self::cache_leg(
|
||||
async move { offline.get_items_by_person(&person_id, opts_clone).await },
|
||||
)
|
||||
.await;
|
||||
|
||||
let server_future =
|
||||
async move { online.get_items_by_person(&person_id_clone, options).await };
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
Self::parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
/// TRACES: UR-067 | DR-115
|
||||
@@ -1092,12 +1163,12 @@ impl MediaRepository for HybridRepository {
|
||||
let item_id = item_id.to_string();
|
||||
let item_id_clone = item_id.clone();
|
||||
|
||||
let cache_future = self
|
||||
.cache_with_timeout(async move { offline.get_similar_items(&item_id, limit).await });
|
||||
let cache_future =
|
||||
Self::cache_leg(async move { offline.get_similar_items(&item_id, limit).await }).await;
|
||||
|
||||
let server_future = async move { online.get_similar_items(&item_id_clone, limit).await };
|
||||
|
||||
self.parallel_race(cache_future, server_future).await
|
||||
Self::parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
// ===== Playlist Methods =====
|
||||
@@ -1228,6 +1299,87 @@ mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Offline, a cache read slowed past the fast path must still answer.
|
||||
///
|
||||
/// The database is one SQLite connection behind one mutex, so a concurrent
|
||||
/// write blocks reads for its duration and the 100 ms fast path trips on
|
||||
/// slow storage. The deadline used to *cancel* the read and report it as a
|
||||
/// miss; with the server leg also failing (offline), the user got a network
|
||||
/// error over cached content that was sitting on disk.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-013
|
||||
#[tokio::test]
|
||||
async fn a_slow_cache_still_answers_when_the_server_is_gone() {
|
||||
let cache = HybridRepository::cache_leg(async {
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
Ok(vec![MediaItem {
|
||||
id: "cached-item".to_string(),
|
||||
..Default::default()
|
||||
}])
|
||||
})
|
||||
.await;
|
||||
|
||||
let server = async {
|
||||
Err(RepoError::Network {
|
||||
message: "offline".to_string(),
|
||||
})
|
||||
};
|
||||
|
||||
let got = HybridRepository::parallel_race(cache, server)
|
||||
.await
|
||||
.expect("a slow cache read must still be delivered when the server is gone");
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].id, "cached-item");
|
||||
}
|
||||
|
||||
/// A cache that beats the deadline still short-circuits the server.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-013
|
||||
#[tokio::test]
|
||||
async fn a_fast_cache_hit_never_reaches_the_server() {
|
||||
let cache = HybridRepository::cache_leg(async {
|
||||
Ok(vec![MediaItem {
|
||||
id: "fast".to_string(),
|
||||
..Default::default()
|
||||
}])
|
||||
})
|
||||
.await;
|
||||
|
||||
let server = async {
|
||||
panic!("the server leg must not run on a cache hit");
|
||||
};
|
||||
|
||||
let got = HybridRepository::parallel_race(cache, server)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(got[0].id, "fast");
|
||||
}
|
||||
|
||||
/// When both sides fail, the server's error is what the caller sees.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-013
|
||||
#[tokio::test]
|
||||
async fn a_failing_slow_cache_reports_the_server_error() {
|
||||
let cache: CacheLeg<Vec<MediaItem>> = HybridRepository::cache_leg(async {
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
Err(RepoError::Database {
|
||||
message: "disk gone".to_string(),
|
||||
})
|
||||
})
|
||||
.await;
|
||||
|
||||
let server = async {
|
||||
Err(RepoError::Network {
|
||||
message: "offline".to_string(),
|
||||
})
|
||||
};
|
||||
|
||||
let err = HybridRepository::parallel_race(cache, server)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, RepoError::Network { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
/// Mock offline repository that tracks queries and saves
|
||||
struct MockOfflineRepo {
|
||||
items: Arc<Mutex<Vec<MediaItem>>>,
|
||||
|
||||
@@ -84,6 +84,30 @@ fn build_fts_prefix_query(query: &str) -> Option<String> {
|
||||
)
|
||||
}
|
||||
|
||||
/// The Jellyfin taxonomy half of "does cached item `i` belong to library `l`":
|
||||
/// the library's `collection_type` against the item's `item_type`.
|
||||
///
|
||||
/// A macro rather than a `const` because both callers need it *inside* a larger
|
||||
/// SQL string literal, and `concat!` cannot take a const. One definition, so the
|
||||
/// two sites cannot drift — they did once already, and opening any downloaded
|
||||
/// library then listed every downloaded item on the server (DR-167).
|
||||
///
|
||||
/// Deliberately has **no fall-open arm**. Adding "…or the type is unknown"
|
||||
/// makes the clause true for every row, which is precisely the defect it exists
|
||||
/// to prevent; callers that want that behaviour must say so themselves and
|
||||
/// justify it, as `LIBRARY_HOLDS_ITEM` does.
|
||||
///
|
||||
/// TRACES: UR-007, UR-055 | DR-167, DR-277
|
||||
macro_rules! library_type_matches_item {
|
||||
() => {
|
||||
"(
|
||||
(l.collection_type = 'music' AND i.item_type IN ('MusicAlbum', 'MusicArtist', 'Audio'))
|
||||
OR (l.collection_type = 'movies' AND i.item_type = 'Movie')
|
||||
OR (l.collection_type = 'tvshows' AND i.item_type IN ('Series', 'Season', 'Episode'))
|
||||
)"
|
||||
};
|
||||
}
|
||||
|
||||
pub struct OfflineRepository {
|
||||
db_service: Arc<RusqliteService>,
|
||||
server_id: String,
|
||||
@@ -422,12 +446,81 @@ impl OfflineRepository {
|
||||
result
|
||||
}
|
||||
|
||||
/// Which library the children of `parent_id` belong to.
|
||||
///
|
||||
/// `Some(parent_id)` when the parent is itself a library, otherwise the
|
||||
/// library the parent item was already filed under — so the association
|
||||
/// propagates down a hierarchy as it is browsed, without needing the server
|
||||
/// to repeat it on every item. `None` for a parent that is neither, which
|
||||
/// is how synthetic parents like "favorites" avoid being filed anywhere.
|
||||
///
|
||||
/// TRACES: UR-007 | DR-278
|
||||
async fn resolve_owning_library(&self, parent_id: &str) -> Option<String> {
|
||||
let is_library: Option<String> = self
|
||||
.db_service
|
||||
.query_optional(
|
||||
Query::with_params(
|
||||
"SELECT id FROM libraries WHERE id = ? AND server_id = ?",
|
||||
vec![
|
||||
QueryParam::String(parent_id.to_string()),
|
||||
QueryParam::String(self.server_id.clone()),
|
||||
],
|
||||
),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
if is_library.is_some() {
|
||||
return is_library;
|
||||
}
|
||||
|
||||
self.db_service
|
||||
.query_optional(
|
||||
Query::with_params(
|
||||
"SELECT library_id FROM items WHERE id = ? AND library_id IS NOT NULL",
|
||||
vec![QueryParam::String(parent_id.to_string())],
|
||||
),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
async fn save_to_cache_impl(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
items: &[MediaItem],
|
||||
now: &str,
|
||||
) -> Result<usize, RepoError> {
|
||||
// Which library do these items belong to?
|
||||
//
|
||||
// Resolved once per call, from the parent being browsed. Two cases and
|
||||
// nothing else:
|
||||
//
|
||||
// * the parent IS a library -> these are its direct children
|
||||
// * the parent is an item -> inherit whatever library that item is
|
||||
// already known to belong to, so tracks
|
||||
// under an album and episodes under a
|
||||
// season land in the same library as
|
||||
// their container
|
||||
//
|
||||
// Synthetic parents ("favorites" and friends) match neither and stay
|
||||
// NULL, which is correct: they are not a library and their contents
|
||||
// span several.
|
||||
//
|
||||
// Until this existed, `library_id` was bound NULL for every cached row
|
||||
// and the only way to associate an item with a library was the
|
||||
// `collection_type` ↔ `item_type` taxonomy. That cannot tell two
|
||||
// libraries of the *same* type apart — a server with "TV" and "Shows"
|
||||
// served both the same contents — and has nothing to say about a
|
||||
// library whose type it does not map (DR-278).
|
||||
//
|
||||
// TRACES: UR-007 | DR-278
|
||||
let owning_library = self.resolve_owning_library(parent_id).await;
|
||||
|
||||
// Collect all unique parent IDs referenced by items being saved
|
||||
let mut parent_ids = std::collections::HashSet::new();
|
||||
parent_ids.insert(parent_id.to_string());
|
||||
@@ -554,8 +647,12 @@ impl OfflineRepository {
|
||||
vec![
|
||||
QueryParam::String(item.id.clone()),
|
||||
QueryParam::String(self.server_id.clone()),
|
||||
// Library is NULL for cached items (may not be synced yet)
|
||||
QueryParam::Null, // library_id
|
||||
// The library this browse belongs to; NULL only for
|
||||
// synthetic parents. See `resolve_owning_library`.
|
||||
match &owning_library {
|
||||
Some(lib) => QueryParam::String(lib.clone()),
|
||||
None => QueryParam::Null,
|
||||
}, // library_id
|
||||
// Use the item's actual parent_id, not the function parameter
|
||||
match &item.parent_id {
|
||||
Some(pid) => QueryParam::String(pid.clone()),
|
||||
@@ -856,13 +953,13 @@ impl OfflineRepository {
|
||||
/// no mapping to narrow it by and hiding its contents would be worse.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082, DR-167
|
||||
const LIBRARY_HOLDS_ITEM: &'static str = "(
|
||||
(l.collection_type = 'music' AND i.item_type IN ('MusicAlbum', 'MusicArtist', 'Audio'))
|
||||
OR (l.collection_type = 'movies' AND i.item_type = 'Movie')
|
||||
OR (l.collection_type = 'tvshows' AND i.item_type IN ('Series', 'Season', 'Episode'))
|
||||
OR l.collection_type IS NULL
|
||||
OR l.collection_type NOT IN ('music', 'movies', 'tvshows')
|
||||
)";
|
||||
const LIBRARY_HOLDS_ITEM: &'static str = concat!(
|
||||
"(",
|
||||
library_type_matches_item!(),
|
||||
" OR l.collection_type IS NULL
|
||||
OR l.collection_type NOT IN ('music', 'movies', 'tvshows')
|
||||
)"
|
||||
);
|
||||
|
||||
/// TRACES: UR-055 | DR-082, DR-083
|
||||
const DOWNLOADED_ITEMS_CTE: &'static str = "
|
||||
@@ -1358,14 +1455,43 @@ impl MediaRepository for OfflineRepository {
|
||||
-- so match every item on the server and let the type filter
|
||||
-- (e.g. MusicAlbum / Movie / Series) narrow it. This is what
|
||||
-- makes library landing pages show albums/movies/shows offline.
|
||||
--
|
||||
-- The type correlation is NOT optional. Without it this
|
||||
-- EXISTS never mentions the item, so it is true for every
|
||||
-- cached row as soon as the requested parent is any library.
|
||||
-- Music/Movies/TV got away with that because their landing
|
||||
-- pages pass `include_item_types`, which narrowed the result;
|
||||
-- the generic library page passes none, so a Books or Photos
|
||||
-- library served the entire cached server (DR-277).
|
||||
--
|
||||
-- `library_id` wins wherever it survived the cache write:
|
||||
-- it is the server's own answer, and it is the only thing
|
||||
-- that can scope a library whose type has no mapping (Books,
|
||||
-- Photos, Collections) or none at all (a mixed library, where
|
||||
-- Jellyfin sends CollectionType null). The taxonomy is the
|
||||
-- fallback for rows that predate it being stored.
|
||||
--
|
||||
-- A library with neither a stored link nor a mapped type now
|
||||
-- matches nothing here and falls through to the server, which
|
||||
-- does know what is in it. Showing nothing briefly beats
|
||||
-- showing somebody else's films with confidence.
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM libraries l
|
||||
WHERE l.id = ? AND l.server_id = i.server_id
|
||||
AND (
|
||||
i.library_id = l.id
|
||||
OR (i.library_id IS NULL AND {})
|
||||
)
|
||||
)
|
||||
){}{}
|
||||
ORDER BY {}
|
||||
LIMIT {} OFFSET {}",
|
||||
type_filter, favorites_filter, order_by, limit, start_index
|
||||
library_type_matches_item!(),
|
||||
type_filter,
|
||||
favorites_filter,
|
||||
order_by,
|
||||
limit,
|
||||
start_index
|
||||
);
|
||||
|
||||
// The requested id is compared against every hierarchy-linkage column
|
||||
@@ -4587,6 +4713,27 @@ mod tests {
|
||||
|
||||
let db_service = create_test_db();
|
||||
seed_favorites(&db_service).await;
|
||||
|
||||
// A favourite album *in lib-1*. The fixture's `album-fav` lives in
|
||||
// lib-2, and this test used to expect it back from a lib-1 listing —
|
||||
// which only held because the library clause matched every cached row
|
||||
// regardless of which library it was in (DR-277). The assertions below
|
||||
// still span both requested types, which is what UT-206 is really about;
|
||||
// they now do it with an album that is actually in the library.
|
||||
db_service
|
||||
.execute(Query::new(
|
||||
"INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
|
||||
VALUES ('album-lib1', 'test-server', 'Album In Lib One', 'MusicAlbum', 'lib-1', '2026-01-01', 'Album In Lib One')",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
db_service
|
||||
.execute(Query::new(
|
||||
"INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'album-lib1', 1)",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let repo = OfflineRepository::new(
|
||||
db_service,
|
||||
"test-server".to_string(),
|
||||
@@ -4605,7 +4752,11 @@ mod tests {
|
||||
.unwrap();
|
||||
let mut ids: Vec<&str> = both.items.iter().map(|i| i.id.as_str()).collect();
|
||||
ids.sort();
|
||||
assert_eq!(ids, vec!["album-fav", "movie-fav", "movie-plain"]);
|
||||
assert_eq!(ids, vec!["album-lib1", "movie-fav", "movie-plain"]);
|
||||
assert!(
|
||||
!ids.contains(&"album-fav"),
|
||||
"album-fav belongs to lib-2 and must not appear in a lib-1 listing"
|
||||
);
|
||||
|
||||
// Two type placeholders *and* the favourites parameter after them.
|
||||
let favourites = repo
|
||||
@@ -4621,7 +4772,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let mut ids: Vec<&str> = favourites.items.iter().map(|i| i.id.as_str()).collect();
|
||||
ids.sort();
|
||||
assert_eq!(ids, vec!["album-fav", "movie-fav"]);
|
||||
assert_eq!(ids, vec!["album-lib1", "movie-fav"]);
|
||||
}
|
||||
|
||||
/// UT-102 — caching a server result mirrors its favourite state locally,
|
||||
@@ -4937,4 +5088,215 @@ mod tests {
|
||||
"a position with no favourite flag must still be mirrored"
|
||||
);
|
||||
}
|
||||
|
||||
/// A library whose `collection_type` is not one of the three the app has
|
||||
/// landing pages for — Books, Photos, Home Videos, Collections, or a mixed
|
||||
/// library — must not show the entire server.
|
||||
///
|
||||
/// The cache has no item→library link at all (`library_id`/`parent_id` are
|
||||
/// NULL, see [[offline-libraries-never-cached]]), so `get_items` matched a
|
||||
/// library parent with an EXISTS that never referenced the item:
|
||||
///
|
||||
/// OR EXISTS (SELECT 1 FROM libraries l WHERE l.id = ? AND ...)
|
||||
///
|
||||
/// True for every cached row the moment the requested id is any library.
|
||||
/// The music/movies/TV landing pages got away with it because each passes
|
||||
/// `include_item_types`, which narrowed the result; the generic library page
|
||||
/// passes none, so opening a Books library served whatever happened to be
|
||||
/// cached — films, albums, episodes. Same defect the downloaded listing had
|
||||
/// in DR-167, in the path nobody re-checked.
|
||||
///
|
||||
/// TRACES: UR-007, UR-055 | DR-277 | UT-247
|
||||
#[tokio::test]
|
||||
async fn test_get_items_unknown_library_type_does_not_return_whole_server() {
|
||||
// Shared global: other tests flip it, so hold the lock and
|
||||
// state it explicitly rather than inheriting whatever ran last.
|
||||
let _guard = lock_catalog_browse();
|
||||
set_include_catalog_browse(true);
|
||||
|
||||
let db = create_test_db();
|
||||
|
||||
insert_item(&db, "movie-1", "Movie", None, None, None).await;
|
||||
insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
|
||||
insert_item(&db, "series-1", "Series", None, None, None).await;
|
||||
|
||||
// Every library kind the app has no landing page for, including the
|
||||
// empty `collection_type` Jellyfin sends for a mixed library.
|
||||
for collection_type in ["books", "boxsets", "photos", "homevideos", ""] {
|
||||
let lib = format!("lib-{collection_type}");
|
||||
seed_library(&db, &lib, collection_type).await;
|
||||
|
||||
let repo = make_repo(&db);
|
||||
let ids: Vec<String> = repo
|
||||
.get_items(&lib, None)
|
||||
.await
|
||||
.unwrap()
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| i.id.clone())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
ids.is_empty(),
|
||||
"a '{collection_type}' library must not serve the server's films, \
|
||||
albums and shows; got {:?}",
|
||||
ids
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Two libraries of the *same* type are still two libraries. A server with
|
||||
/// "TV" and "Shows" — or "Films" and "Kids Films" — must not serve both the
|
||||
/// same contents.
|
||||
///
|
||||
/// The taxonomy fallback cannot tell them apart: it matches on
|
||||
/// `collection_type`, which is identical for both, so every Series on the
|
||||
/// server satisfies either one. Only the stored `library_id` can separate
|
||||
/// them, which is why populating it is the real fix rather than a nicety.
|
||||
///
|
||||
/// TRACES: UR-007 | DR-277 | UT-250
|
||||
#[tokio::test]
|
||||
async fn test_get_items_two_libraries_of_one_type_are_not_interchangeable() {
|
||||
// Shared global: other tests flip it, so hold the lock and
|
||||
// state it explicitly rather than inheriting whatever ran last.
|
||||
let _guard = lock_catalog_browse();
|
||||
set_include_catalog_browse(true);
|
||||
|
||||
let db = create_test_db();
|
||||
seed_library(&db, "tv-lib", "tvshows").await;
|
||||
seed_library(&db, "shows-lib", "tvshows").await;
|
||||
|
||||
let repo = make_repo(&db);
|
||||
|
||||
// Seeded through the real write path, because that is what the fix
|
||||
// changes: browsing a library is what files its contents under it.
|
||||
for (id, lib) in [("series-a", "tv-lib"), ("series-b", "shows-lib")] {
|
||||
let mut item = create_test_item(id, id, None);
|
||||
item.item_type = "Series".to_string();
|
||||
item.kind = crate::domain::MediaKind::Series;
|
||||
repo.save_to_cache(lib, &[item]).await.unwrap();
|
||||
}
|
||||
for (lib, own, other) in [
|
||||
("tv-lib", "series-a", "series-b"),
|
||||
("shows-lib", "series-b", "series-a"),
|
||||
] {
|
||||
let ids: Vec<String> = repo
|
||||
.get_items(lib, None)
|
||||
.await
|
||||
.unwrap()
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| i.id.clone())
|
||||
.collect();
|
||||
assert!(
|
||||
ids.contains(&own.to_string()),
|
||||
"{lib} should list {own}; got {:?}",
|
||||
ids
|
||||
);
|
||||
assert!(
|
||||
!ids.contains(&other.to_string()),
|
||||
"{lib} must not list {other}, which lives in the other library; got {:?}",
|
||||
ids
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opening an individual collection is a different path and must keep
|
||||
/// working: a BoxSet's children carry `parent_id`, which the cache does
|
||||
/// store, so they are matched by the ordinary parent link rather than by
|
||||
/// the library clause this fix narrowed.
|
||||
///
|
||||
/// Worth pinning separately — narrowing the library clause could plausibly
|
||||
/// have taken collections with it, and "Collections is empty" would look
|
||||
/// identical to the bug it was meant to fix.
|
||||
///
|
||||
/// TRACES: UR-007 | DR-277 | UT-249
|
||||
#[tokio::test]
|
||||
async fn test_get_items_collection_lists_its_own_children() {
|
||||
// Shared global: other tests flip it, so hold the lock and
|
||||
// state it explicitly rather than inheriting whatever ran last.
|
||||
let _guard = lock_catalog_browse();
|
||||
set_include_catalog_browse(true);
|
||||
|
||||
let db = create_test_db();
|
||||
seed_library(&db, "boxset-lib", "boxsets").await;
|
||||
|
||||
insert_item(&db, "boxset-1", "BoxSet", None, None, None).await;
|
||||
insert_item(&db, "outsider", "Movie", None, None, None).await;
|
||||
|
||||
// A film inside the collection: linked by parent_id, which is what a
|
||||
// BoxSet's children actually carry.
|
||||
db.execute(Query::with_params(
|
||||
"INSERT INTO items (id, server_id, name, item_type, parent_id, synced_at) \
|
||||
VALUES ('in-set', 'test-server', 'In The Set', 'Movie', ?1, '2024-01-01')",
|
||||
vec![QueryParam::String("boxset-1".to_string())],
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let repo = make_repo(&db);
|
||||
let ids: Vec<String> = repo
|
||||
.get_items("boxset-1", None)
|
||||
.await
|
||||
.unwrap()
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| i.id.clone())
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec!["in-set".to_string()],
|
||||
"a collection lists its own children and nothing else; got {:?}",
|
||||
ids
|
||||
);
|
||||
}
|
||||
|
||||
/// The narrowing must not break the libraries that *do* have landing pages:
|
||||
/// they reach the same query and must keep returning their own media.
|
||||
///
|
||||
/// TRACES: UR-007 | DR-277 | UT-248
|
||||
#[tokio::test]
|
||||
async fn test_get_items_typed_libraries_still_return_their_own_media() {
|
||||
// Shared global: other tests flip it, so hold the lock and
|
||||
// state it explicitly rather than inheriting whatever ran last.
|
||||
let _guard = lock_catalog_browse();
|
||||
set_include_catalog_browse(true);
|
||||
|
||||
let db = create_test_db();
|
||||
seed_library(&db, "music-lib", "music").await;
|
||||
seed_library(&db, "movie-lib", "movies").await;
|
||||
seed_library(&db, "tv-lib", "tvshows").await;
|
||||
|
||||
insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
|
||||
insert_item(&db, "movie-1", "Movie", None, None, None).await;
|
||||
insert_item(&db, "series-1", "Series", None, None, None).await;
|
||||
|
||||
let repo = make_repo(&db);
|
||||
|
||||
for (lib, expected, forbidden) in [
|
||||
("music-lib", "album-1", "movie-1"),
|
||||
("movie-lib", "movie-1", "album-1"),
|
||||
("tv-lib", "series-1", "album-1"),
|
||||
] {
|
||||
let ids: Vec<String> = repo
|
||||
.get_items(lib, None)
|
||||
.await
|
||||
.unwrap()
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| i.id.clone())
|
||||
.collect();
|
||||
assert!(
|
||||
ids.contains(&expected.to_string()),
|
||||
"{lib} should list {expected}; got {:?}",
|
||||
ids
|
||||
);
|
||||
assert!(
|
||||
!ids.contains(&forbidden.to_string()),
|
||||
"{lib} must not list {forbidden}; got {:?}",
|
||||
ids
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! - Test with different database backends
|
||||
//! - Migrate to other database systems in the future
|
||||
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use async_trait::async_trait;
|
||||
use rusqlite::{params_from_iter, Connection, Result as SqliteResult, Row};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -128,9 +129,10 @@ impl DatabaseService for RusqliteService {
|
||||
async fn execute(&self, query: Query) -> DbResult<usize> {
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
||||
// panic under the guard would otherwise poison it, failing every
|
||||
// later query with "poisoned lock" until the process restarts.
|
||||
let conn = conn.lock_safe();
|
||||
execute_query(&conn, query)
|
||||
})
|
||||
.await
|
||||
@@ -141,9 +143,10 @@ impl DatabaseService for RusqliteService {
|
||||
let conn = Arc::clone(&self.conn);
|
||||
let sql = sql.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
||||
// panic under the guard would otherwise poison it, failing every
|
||||
// later query with "poisoned lock" until the process restarts.
|
||||
let conn = conn.lock_safe();
|
||||
conn.execute_batch(&sql)
|
||||
.map_err(|e| format!("Execute batch failed: {}", e))
|
||||
})
|
||||
@@ -158,9 +161,10 @@ impl DatabaseService for RusqliteService {
|
||||
{
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
||||
// panic under the guard would otherwise poison it, failing every
|
||||
// later query with "poisoned lock" until the process restarts.
|
||||
let conn = conn.lock_safe();
|
||||
query_one(&conn, query, mapper)
|
||||
})
|
||||
.await
|
||||
@@ -174,9 +178,10 @@ impl DatabaseService for RusqliteService {
|
||||
{
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
||||
// panic under the guard would otherwise poison it, failing every
|
||||
// later query with "poisoned lock" until the process restarts.
|
||||
let conn = conn.lock_safe();
|
||||
query_optional(&conn, query, mapper)
|
||||
})
|
||||
.await
|
||||
@@ -190,9 +195,10 @@ impl DatabaseService for RusqliteService {
|
||||
{
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
||||
// panic under the guard would otherwise poison it, failing every
|
||||
// later query with "poisoned lock" until the process restarts.
|
||||
let conn = conn.lock_safe();
|
||||
query_many(&conn, query, mapper)
|
||||
})
|
||||
.await
|
||||
@@ -206,9 +212,10 @@ impl DatabaseService for RusqliteService {
|
||||
{
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
||||
// panic under the guard would otherwise poison it, failing every
|
||||
// later query with "poisoned lock" until the process restarts.
|
||||
let conn = conn.lock_safe();
|
||||
|
||||
conn.execute("BEGIN TRANSACTION", [])
|
||||
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
|
||||
@@ -236,9 +243,10 @@ impl DatabaseService for RusqliteService {
|
||||
async fn last_insert_rowid(&self) -> DbResult<i64> {
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = conn
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
||||
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
||||
// panic under the guard would otherwise poison it, failing every
|
||||
// later query with "poisoned lock" until the process restarts.
|
||||
let conn = conn.lock_safe();
|
||||
Ok(conn.last_insert_rowid())
|
||||
})
|
||||
.await
|
||||
@@ -410,4 +418,48 @@ mod tests {
|
||||
let count: i32 = service.query_one(query, |row| row.get(0)).await.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
}
|
||||
/// A panic while the connection guard is held must not brick every later
|
||||
/// query.
|
||||
///
|
||||
/// This is the single busiest lock in the app — every async DB operation
|
||||
/// goes through it. With a raw `.lock()`, one panic under the guard poisons
|
||||
/// the mutex and every subsequent call returns "poisoned lock" until the
|
||||
/// process restarts, which for a database-backed app means the whole UI
|
||||
/// stops working. `utils::lock` exists precisely to stop that cascade, and
|
||||
/// `storage::Database` already used it; this path did not.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-012 | UT-014
|
||||
#[tokio::test]
|
||||
async fn a_poisoned_connection_still_serves_queries() {
|
||||
let conn = Arc::new(Mutex::new(Connection::open_in_memory().unwrap()));
|
||||
{
|
||||
let c = conn.lock_safe();
|
||||
c.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY);")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Poison the mutex the way a panicking row mapper would.
|
||||
let poisoner = Arc::clone(&conn);
|
||||
let hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(|_| {}));
|
||||
let _ = std::thread::spawn(move || {
|
||||
let _guard = poisoner.lock().unwrap();
|
||||
panic!("a row mapper blew up while holding the connection");
|
||||
})
|
||||
.join();
|
||||
std::panic::set_hook(hook);
|
||||
assert!(conn.lock().is_err(), "the mutex should now be poisoned");
|
||||
|
||||
// Every operation must still work.
|
||||
let service = RusqliteService::new(Arc::clone(&conn));
|
||||
service
|
||||
.execute(Query::new("INSERT INTO test (id) VALUES (1)"))
|
||||
.await
|
||||
.expect("execute must survive a poisoned connection");
|
||||
let count: i32 = service
|
||||
.query_one(Query::new("SELECT COUNT(*) FROM test"), |row| row.get(0))
|
||||
.await
|
||||
.expect("query_one must survive a poisoned connection");
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
}
|
||||
|
||||
+139
-21
@@ -75,8 +75,31 @@ impl Database {
|
||||
Arc::clone(&self.conn)
|
||||
}
|
||||
|
||||
/// Run all pending migrations
|
||||
/// Run all pending migrations.
|
||||
pub fn migrate(&self) -> SqliteResult<()> {
|
||||
self.migrate_with(MIGRATIONS)
|
||||
}
|
||||
|
||||
/// Apply `migrations` in order, skipping ones `_migrations` already records.
|
||||
///
|
||||
/// **Each migration is one transaction, and the `_migrations` row is written
|
||||
/// inside it.** SQLite autocommits every statement otherwise, so a migration
|
||||
/// that failed partway — low disk, an OOM kill, the process dying mid-boot —
|
||||
/// used to leave its earlier statements applied while recording nothing.
|
||||
/// `execute_batch` aborts on the first error, so the retry on the next launch
|
||||
/// then failed at statement 1 ("duplicate column name") and kept failing
|
||||
/// forever; `Database::open` turns that into a panic, so the app never
|
||||
/// started again and the only fix was clearing app data. Committing the
|
||||
/// schema change and the bookkeeping together makes a migration all-or-nothing
|
||||
/// and a retry always safe.
|
||||
///
|
||||
/// Every migration is pure DDL/DML, which SQLite runs transactionally — a
|
||||
/// `PRAGMA` or `VACUUM` added to one would not roll back and must not be.
|
||||
///
|
||||
/// Split out from [`Self::migrate`] so tests can inject a failing migration.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-012 | UT-014
|
||||
fn migrate_with(&self, migrations: &[(&str, &str)]) -> SqliteResult<()> {
|
||||
info!("Starting database migrations...");
|
||||
let conn = self.conn.lock_safe();
|
||||
|
||||
@@ -107,28 +130,30 @@ impl Database {
|
||||
debug!("Found {} applied migrations", applied.len());
|
||||
|
||||
// Apply pending migrations
|
||||
for (name, sql) in MIGRATIONS {
|
||||
if !applied.contains(&name.to_string()) {
|
||||
info!("Applying migration: {}", name);
|
||||
match conn.execute_batch(sql) {
|
||||
Ok(_) => {
|
||||
info!("Successfully applied migration: {}", name);
|
||||
match conn.execute("INSERT INTO _migrations (name) VALUES (?1)", [name]) {
|
||||
Ok(_) => debug!("Recorded migration: {}", name),
|
||||
Err(e) => {
|
||||
error!("Failed to record migration {}: {}", name, e);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to apply migration {}: {}", name, e);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (name, sql) in migrations {
|
||||
if applied.contains(&name.to_string()) {
|
||||
debug!("Skipping already applied migration: {}", name);
|
||||
continue;
|
||||
}
|
||||
|
||||
info!("Applying migration: {}", name);
|
||||
|
||||
// `unchecked_transaction` because the connection is reached through a
|
||||
// shared guard rather than `&mut`. Dropping the transaction without
|
||||
// committing rolls it back, which is exactly what the `?`s below do.
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
|
||||
if let Err(e) = tx.execute_batch(sql) {
|
||||
error!("Failed to apply migration {} (rolled back): {}", name, e);
|
||||
return Err(e);
|
||||
}
|
||||
if let Err(e) = tx.execute("INSERT INTO _migrations (name) VALUES (?1)", [name]) {
|
||||
error!("Failed to record migration {} (rolled back): {}", name, e);
|
||||
return Err(e);
|
||||
}
|
||||
tx.commit()?;
|
||||
|
||||
info!("Successfully applied migration: {}", name);
|
||||
}
|
||||
|
||||
info!("All migrations completed successfully");
|
||||
@@ -166,6 +191,99 @@ mod tests {
|
||||
assert_eq!(db.path().to_str(), Some(":memory:"));
|
||||
}
|
||||
|
||||
/// A migration that dies partway must leave *nothing* behind.
|
||||
///
|
||||
/// SQLite autocommits each statement, so before migrations were wrapped in a
|
||||
/// transaction the first `ADD COLUMN` of a failing batch stuck while the
|
||||
/// `_migrations` row was never written. `execute_batch` aborts on the first
|
||||
/// error, so the retry on the next launch failed at statement 1 with
|
||||
/// "duplicate column name" and kept failing forever — and `Database::open`
|
||||
/// panics on that, so the app never started again.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-012 | UT-014
|
||||
#[test]
|
||||
fn test_failed_migration_rolls_back_and_stays_retryable() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
|
||||
// Statement 1 succeeds, statement 2 fails, statement 3 never runs.
|
||||
let poisoned = &[(
|
||||
"900_partially_failing",
|
||||
"ALTER TABLE downloads ADD COLUMN audit_a TEXT;
|
||||
ALTER TABLE downloads ADD COLUMN audit_b TEXT FROM NOWHERE;
|
||||
ALTER TABLE downloads ADD COLUMN audit_c TEXT;",
|
||||
)][..];
|
||||
|
||||
let first = db.migrate_with(poisoned).unwrap_err();
|
||||
|
||||
// Nothing from the batch may survive, or the retry cannot re-run it.
|
||||
assert!(
|
||||
!has_column(&db, "downloads", "audit_a"),
|
||||
"statement 1 of a failed migration was left applied: the batch did not roll back"
|
||||
);
|
||||
assert!(!has_column(&db, "downloads", "audit_c"));
|
||||
|
||||
// And it must not be recorded as applied.
|
||||
let recorded: i64 = db
|
||||
.connection()
|
||||
.lock_safe()
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM _migrations WHERE name = ?1",
|
||||
["900_partially_failing"],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(recorded, 0, "a failed migration must not be recorded");
|
||||
|
||||
// The retry must fail the same way it did the first time — reaching the
|
||||
// real error — rather than tripping over its own leftovers.
|
||||
let second = db.migrate_with(poisoned).unwrap_err();
|
||||
assert!(
|
||||
!second.to_string().contains("duplicate column"),
|
||||
"the retry hit leftovers from the failed run instead of the real error: {second}"
|
||||
);
|
||||
assert_eq!(first.to_string(), second.to_string());
|
||||
|
||||
// A corrected migration under the same name then applies cleanly.
|
||||
let fixed = &[(
|
||||
"900_partially_failing",
|
||||
"ALTER TABLE downloads ADD COLUMN audit_a TEXT;
|
||||
ALTER TABLE downloads ADD COLUMN audit_c TEXT;",
|
||||
)][..];
|
||||
db.migrate_with(fixed).unwrap();
|
||||
assert!(has_column(&db, "downloads", "audit_a"));
|
||||
assert!(has_column(&db, "downloads", "audit_c"));
|
||||
}
|
||||
|
||||
/// A committed migration is recorded, so it is never applied twice.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-012 | UT-014
|
||||
#[test]
|
||||
fn test_successful_migration_is_recorded_in_the_same_transaction() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let m = &[(
|
||||
"901_adds_a_column",
|
||||
"ALTER TABLE downloads ADD COLUMN audit_d TEXT;",
|
||||
)][..];
|
||||
|
||||
db.migrate_with(m).unwrap();
|
||||
// Re-running must be a no-op, not a "duplicate column" failure.
|
||||
db.migrate_with(m).unwrap();
|
||||
assert!(has_column(&db, "downloads", "audit_d"));
|
||||
}
|
||||
|
||||
fn has_column(db: &Database, table: &str, column: &str) -> bool {
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock_safe();
|
||||
let mut stmt = conn
|
||||
.prepare(&format!("PRAGMA table_info({table})"))
|
||||
.unwrap();
|
||||
let mut names = stmt
|
||||
.query_map([], |row| row.get::<_, String>(1))
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok());
|
||||
names.any(|n| n == column)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrations_run() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
|
||||
@@ -28,6 +28,8 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
|
||||
("021_rebuild_items_fts", MIGRATION_021),
|
||||
("022_people_fts", MIGRATION_022),
|
||||
("023_downloads_expiry", MIGRATION_023),
|
||||
("024_multi_user_profiles", MIGRATION_024),
|
||||
("025_backfill_item_library_id", MIGRATION_025),
|
||||
];
|
||||
|
||||
/// Initial schema migration
|
||||
@@ -819,3 +821,274 @@ ALTER TABLE downloads ADD COLUMN expires_at TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_expiry
|
||||
ON downloads(download_source, expires_at);
|
||||
"#;
|
||||
|
||||
/// Multi-user profiles: PIN gate, per-user cache visibility, and download grants.
|
||||
///
|
||||
/// Three tables, one purpose each:
|
||||
///
|
||||
/// - `user_pins` holds the switching gate. The PIN hash lives here rather than
|
||||
/// wrapping the access token, because a wrapped token would leave a locked
|
||||
/// profile unable to resume its own downloads or drain its own sync queue
|
||||
/// until someone typed the code. See DR-268 for why that trade was taken.
|
||||
/// - `user_item_visibility` records what the server has actually shown to each
|
||||
/// user. It is written as a byproduct of the cache write path, never rebuilt,
|
||||
/// so it cannot disagree with what the server returned.
|
||||
/// - `download_grants` separates the bytes from the claim on them, so one file
|
||||
/// can serve several profiles and is unlinked only when the last claim goes.
|
||||
///
|
||||
/// The backfill is not optional. Every existing cache row and download predates
|
||||
/// the concept of a user; without it an upgrading install's library goes blank.
|
||||
/// It grants the *active* user only — other pre-existing rows re-populate from
|
||||
/// the server on next browse, which is strictly safer than handing every
|
||||
/// profile the whole cache. The `OR (SELECT COUNT(*) ...) = 0` arm covers an
|
||||
/// install whose single user somehow has `is_active = 0`.
|
||||
///
|
||||
/// TRACES: UR-082, UR-083 | DR-268, DR-271, DR-272
|
||||
const MIGRATION_024: &str = r#"
|
||||
CREATE TABLE IF NOT EXISTS user_pins (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
pin_hash TEXT NOT NULL,
|
||||
failed_count INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until TEXT,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_visibility_user ON user_item_visibility(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_libraries (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
library_id TEXT NOT NULL,
|
||||
seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, library_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_grants_download ON download_grants(download_id);
|
||||
|
||||
-- Backfill: the active user has seen everything already cached on this device.
|
||||
INSERT OR IGNORE INTO user_item_visibility (user_id, item_id)
|
||||
SELECT u.id, i.id
|
||||
FROM users u CROSS JOIN items i
|
||||
WHERE u.is_active = 1
|
||||
OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0;
|
||||
|
||||
INSERT OR IGNORE INTO user_libraries (user_id, library_id)
|
||||
SELECT u.id, l.id
|
||||
FROM users u CROSS JOIN libraries l
|
||||
WHERE u.is_active = 1
|
||||
OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0;
|
||||
|
||||
-- Downloads already record who asked for them, so every existing row becomes
|
||||
-- exactly one grant held by its original requester.
|
||||
INSERT OR IGNORE INTO download_grants (user_id, download_id)
|
||||
SELECT d.user_id, d.id FROM downloads d;
|
||||
"#;
|
||||
|
||||
/// Force cached items to be re-fetched so `library_id` is populated.
|
||||
///
|
||||
/// `save_to_cache` bound `library_id` NULL for every row it wrote, so nothing in
|
||||
/// the cache knew which library it came from. The only available association was
|
||||
/// the `collection_type` ↔ `item_type` taxonomy, which cannot tell two libraries
|
||||
/// of the same type apart — a server with "TV" and "Shows" served both the same
|
||||
/// contents — and says nothing at all about a library whose type it does not map
|
||||
/// (Books, Photos, Collections, or a mixed library where Jellyfin sends no
|
||||
/// collection type).
|
||||
///
|
||||
/// The write path now records the library. Existing rows cannot be repaired
|
||||
/// locally — the association was never stored — so they are marked stale and
|
||||
/// re-fetched on next browse, exactly as MIGRATION_018 did for `is_folder`.
|
||||
///
|
||||
/// Deliberately does not delete anything: downloads, favourites and playback
|
||||
/// positions live in other tables and are untouched, and a cleared `synced_at`
|
||||
/// only means "ask the server again", so an offline user keeps browsing what
|
||||
/// they already had until the next successful fetch.
|
||||
///
|
||||
/// TRACES: UR-007 | DR-278
|
||||
const MIGRATION_025: &str = r#"
|
||||
UPDATE items SET synced_at = NULL;
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod migration_024_tests {
|
||||
use super::*;
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
/// Build a database at the schema version *before* multi-user profiles, so
|
||||
/// the backfill is exercised against rows that predate it — which is the
|
||||
/// only state that matters, and the one an in-memory database created from
|
||||
/// the full migration list can never reproduce.
|
||||
fn pre_024_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
let upto = MIGRATIONS
|
||||
.iter()
|
||||
.position(|(name, _)| *name == "024_multi_user_profiles")
|
||||
.expect("migration 024 must be registered");
|
||||
for (_, sql) in &MIGRATIONS[..upto] {
|
||||
conn.execute_batch(sql).unwrap();
|
||||
}
|
||||
conn
|
||||
}
|
||||
|
||||
fn seed(conn: &Connection, active_user: &str) {
|
||||
conn.execute(
|
||||
"INSERT INTO servers (id, name, url) VALUES ('s1', 'Test', 'http://localhost:8096')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username, is_active) VALUES (?1, 's1', 'dad', 1)",
|
||||
params![active_user],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO libraries (id, server_id, name) VALUES ('lib1', 's1', 'Movies')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO items (id, server_id, name, item_type) VALUES ('i1', 's1', 'A Movie', 'Movie')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO downloads (item_id, user_id, file_path, status)
|
||||
VALUES ('i1', ?1, 'downloads/a.mp4', 'completed')",
|
||||
params![active_user],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn apply_024(conn: &Connection) {
|
||||
let (_, sql) = MIGRATIONS
|
||||
.iter()
|
||||
.find(|(name, _)| *name == "024_multi_user_profiles")
|
||||
.unwrap();
|
||||
conn.execute_batch(sql).unwrap();
|
||||
}
|
||||
|
||||
fn count(conn: &Connection, sql: &str) -> i64 {
|
||||
conn.query_row(sql, [], |r| r.get(0)).unwrap()
|
||||
}
|
||||
|
||||
/// UT: upgrading an existing install does not blank its library. Without the
|
||||
/// backfill every cached item becomes invisible to the only user there is.
|
||||
#[test]
|
||||
fn backfill_keeps_the_existing_library_visible() {
|
||||
let conn = pre_024_db();
|
||||
seed(&conn, "dad");
|
||||
apply_024(&conn);
|
||||
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'dad' AND item_id = 'i1'"
|
||||
),
|
||||
1,
|
||||
"the active user must still see what was already cached"
|
||||
);
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT COUNT(*) FROM user_libraries WHERE user_id = 'dad' AND library_id = 'lib1'"
|
||||
),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: an existing download becomes exactly one grant, held by whoever asked
|
||||
/// for it — the file is not orphaned and is not handed to anyone else.
|
||||
#[test]
|
||||
fn backfill_grants_downloads_to_their_requester() {
|
||||
let conn = pre_024_db();
|
||||
seed(&conn, "dad");
|
||||
apply_024(&conn);
|
||||
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT COUNT(*) FROM download_grants WHERE user_id = 'dad'"
|
||||
),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: a second profile added later starts with an empty view. The cache was
|
||||
/// filled by someone else's browsing and the server never showed it to them,
|
||||
/// so inheriting it is the leak this whole table exists to close.
|
||||
#[test]
|
||||
fn a_later_profile_inherits_nothing() {
|
||||
let conn = pre_024_db();
|
||||
seed(&conn, "dad");
|
||||
apply_024(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username, is_active) VALUES ('kid', 's1', 'kid', 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'kid'"
|
||||
),
|
||||
0,
|
||||
"a profile added after the upgrade must not inherit another's cache"
|
||||
);
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT COUNT(*) FROM download_grants WHERE user_id = 'kid'"
|
||||
),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: an install whose sole user somehow has `is_active = 0` still gets its
|
||||
/// library back — the fallback arm of the backfill.
|
||||
#[test]
|
||||
fn backfill_covers_an_install_with_no_active_flag() {
|
||||
let conn = pre_024_db();
|
||||
seed(&conn, "dad");
|
||||
conn.execute("UPDATE users SET is_active = 0", []).unwrap();
|
||||
apply_024(&conn);
|
||||
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'dad'"
|
||||
),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: removing a profile takes its per-user rows with it, so a re-added
|
||||
/// account starts clean rather than resuming someone's stale view.
|
||||
#[test]
|
||||
fn removing_a_profile_cascades_its_rows() {
|
||||
let conn = pre_024_db();
|
||||
seed(&conn, "dad");
|
||||
apply_024(&conn);
|
||||
conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
|
||||
|
||||
conn.execute("DELETE FROM users WHERE id = 'dad'", [])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM user_item_visibility"), 0);
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM user_libraries"), 0);
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM download_grants"), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "JellyTau",
|
||||
"version": "0.11.4",
|
||||
"version": "0.11.6",
|
||||
"identifier": "com.dtourolle.jellytau",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
@@ -1826,6 +1826,107 @@ async playlistGetItems(handle: string, playlistId: string) : Promise<PlaylistEnt
|
||||
async playlistAddItems(handle: string, playlistId: string, itemIds: string[]) : Promise<null> {
|
||||
return await TAURI_INVOKE("playlist_add_items", { handle, playlistId, itemIds });
|
||||
},
|
||||
/**
|
||||
* Add another account from the **current** server to this device.
|
||||
*
|
||||
* Takes no server URL. That is the same-server constraint expressed as a
|
||||
* signature rather than as form validation: there is no way to ask this command
|
||||
* for an account somewhere else.
|
||||
*
|
||||
* TRACES: UR-082 | DR-267
|
||||
*/
|
||||
async profilesAdd(username: string, password: string, pinCode: string | null, deviceId: string) : Promise<Profile> {
|
||||
return await TAURI_INVOKE("profiles_add", { username, password, pinCode, deviceId });
|
||||
},
|
||||
/**
|
||||
* Read the "ask who's watching on start" setting.
|
||||
*
|
||||
* Separate from [`profiles_startup_target`] on purpose: the target can be
|
||||
* `Picker` for reasons that have nothing to do with this setting — a
|
||||
* PIN-protected last profile always asks — so deriving the toggle's position
|
||||
* from it would show the user a switch that does not describe what it controls.
|
||||
*
|
||||
* TRACES: UR-082 | DR-274
|
||||
*/
|
||||
async profilesGetAskOnStart() : Promise<boolean> {
|
||||
return await TAURI_INVOKE("profiles_get_ask_on_start");
|
||||
},
|
||||
/**
|
||||
* List the accounts this device knows for the current server.
|
||||
*
|
||||
* TRACES: UR-082 | DR-267
|
||||
*/
|
||||
async profilesList() : Promise<Profile[]> {
|
||||
return await TAURI_INVOKE("profiles_list");
|
||||
},
|
||||
/**
|
||||
* Forget a profile on this device.
|
||||
*
|
||||
* Does not call Jellyfin's logout endpoint: removing an account from the family
|
||||
* TV should not sign that person out on their phone. The stored token is
|
||||
* deleted locally, which is the part that actually belongs to this device.
|
||||
*
|
||||
* TRACES: UR-082 | DR-267
|
||||
*/
|
||||
async profilesRemove(userId: string) : Promise<null> {
|
||||
return await TAURI_INVOKE("profiles_remove", { userId });
|
||||
},
|
||||
/**
|
||||
* Turn "ask who's watching on start" on or off.
|
||||
*
|
||||
* TRACES: UR-082 | DR-274
|
||||
*/
|
||||
async profilesSetAskOnStart(enabled: boolean) : Promise<null> {
|
||||
return await TAURI_INVOKE("profiles_set_ask_on_start", { enabled });
|
||||
},
|
||||
/**
|
||||
* Set, change, or clear a profile's PIN.
|
||||
*
|
||||
* Changing an existing PIN requires the current one. Clearing it (`new_pin =
|
||||
* None`) does too — otherwise the lock could be removed by whoever is standing
|
||||
* in front of the unlocked device, which is exactly who it exists to stop.
|
||||
*
|
||||
* TRACES: UR-083 | DR-268
|
||||
*/
|
||||
async profilesSetPin(userId: string, currentPin: string | null, newPin: string | null) : Promise<null> {
|
||||
return await TAURI_INVOKE("profiles_set_pin", { userId, currentPin, newPin });
|
||||
},
|
||||
/**
|
||||
* Whether startup should resume an account or ask who is watching.
|
||||
*
|
||||
* The decision is backend state, so the frontend asks rather than computes it.
|
||||
*
|
||||
* TRACES: UR-082 | DR-274
|
||||
*/
|
||||
async profilesStartupTarget() : Promise<StartupTarget> {
|
||||
return await TAURI_INVOKE("profiles_startup_target");
|
||||
},
|
||||
/**
|
||||
* Enter a profile, with its PIN if it has one.
|
||||
*
|
||||
* A profile with no PIN ignores whatever `pin` was passed — the frontend cannot
|
||||
* invent a lock the backend does not have, and cannot skip one it does.
|
||||
*
|
||||
* TRACES: UR-082, UR-083 | DR-267, DR-268, DR-270
|
||||
*/
|
||||
async profilesUnlock(userId: string, pinCode: string | null) : Promise<UnlockOutcome> {
|
||||
return await TAURI_INVOKE("profiles_unlock", { userId, pinCode });
|
||||
},
|
||||
/**
|
||||
* Enter a profile with its Jellyfin password, for someone who has forgotten
|
||||
* their PIN.
|
||||
*
|
||||
* There is deliberately no reset token and no recovery secret: the account's
|
||||
* own password is already the authority over it, and a second credential
|
||||
* guarding the same thing would only be a weaker one. A successful password
|
||||
* entry also clears the lockout, which is what makes a forgotten PIN a
|
||||
* detour rather than a dead end.
|
||||
*
|
||||
* TRACES: UR-084 | DR-269
|
||||
*/
|
||||
async profilesUnlockWithPassword(userId: string, password: string, deviceId: string) : Promise<UnlockOutcome> {
|
||||
return await TAURI_INVOKE("profiles_unlock_with_password", { userId, password, deviceId });
|
||||
},
|
||||
/**
|
||||
* Remove items from a playlist (uses PlaylistItemId entry IDs, NOT media item IDs)
|
||||
*/
|
||||
@@ -3198,6 +3299,16 @@ alreadyDownloaded: number;
|
||||
* Number of tracks skipped (no jellyfin ID or other reasons)
|
||||
*/
|
||||
skipped: number }
|
||||
/**
|
||||
* A switchable account on this device.
|
||||
*
|
||||
* TRACES: UR-082 | DR-267
|
||||
*/
|
||||
export type Profile = { userId: string; username: string; serverId: string;
|
||||
/**
|
||||
* Jellyfin's primary-image tag, for the tile. `None` renders initials.
|
||||
*/
|
||||
avatarTag: string | null; unlockMethod: UnlockMethod; lastUsedAt: string | null; isActive: boolean }
|
||||
/**
|
||||
* One rung of the quality picker, as it applies to *this* media source.
|
||||
*
|
||||
@@ -3358,6 +3469,25 @@ export type SleepTimerState = { mode: SleepTimerMode; remainingSeconds: number }
|
||||
* SmartCache statistics
|
||||
*/
|
||||
export type SmartCacheStats = { total_size: number; storage_limit: number; available_space: number; items_count: number; config: CacheConfig }
|
||||
/**
|
||||
* What the app should do when it starts.
|
||||
*
|
||||
* The decision is backend state (profile count, PIN presence, a stored
|
||||
* setting), so the frontend asks rather than computes. A single account with no
|
||||
* PIN always resumes, which is what keeps this feature invisible until it is
|
||||
* wanted.
|
||||
*
|
||||
* TRACES: UR-082 | DR-274
|
||||
*/
|
||||
export type StartupTarget =
|
||||
/**
|
||||
* Resume this profile without asking.
|
||||
*/
|
||||
{ type: "resume"; userId: string } |
|
||||
/**
|
||||
* Show the picker.
|
||||
*/
|
||||
{ type: "picker" }
|
||||
/**
|
||||
* Storage statistics for downloads
|
||||
*/
|
||||
@@ -3558,6 +3688,50 @@ export type Transport =
|
||||
* server standing in front of one.
|
||||
*/
|
||||
{ type: "localFile" }
|
||||
/**
|
||||
* How a profile is entered.
|
||||
*
|
||||
* Deliberately not "adult"/"child": the app has no way to know a person's age
|
||||
* and no business encoding one. It knows whether a code is set.
|
||||
*
|
||||
* TRACES: UR-083 | DR-276
|
||||
*/
|
||||
export type UnlockMethod =
|
||||
/**
|
||||
* One tap. No code set.
|
||||
*/
|
||||
"none" |
|
||||
/**
|
||||
* A numeric code gates the switch.
|
||||
*/
|
||||
"pin"
|
||||
/**
|
||||
* The result of an unlock attempt.
|
||||
*
|
||||
* Note the explicit field renames. tauri-specta emits tagged-union *fields*
|
||||
* with their Rust names rather than camelCasing them, so a field that would
|
||||
* differ between the two conventions is renamed here by hand — the same trap
|
||||
* that produced `new_url` on the frontend once already.
|
||||
*
|
||||
* TRACES: UR-083, UR-084 | DR-268, DR-269
|
||||
*/
|
||||
export type UnlockOutcome =
|
||||
/**
|
||||
* Switched. The profile is now active.
|
||||
*/
|
||||
{ type: "ok"; userId: string } |
|
||||
/**
|
||||
* Wrong code, attempts left.
|
||||
*/
|
||||
{ type: "wrongPin"; attemptsRemaining: number } |
|
||||
/**
|
||||
* Too many wrong codes; refused until this RFC3339 instant.
|
||||
*/
|
||||
{ type: "lockedOut"; until: string } |
|
||||
/**
|
||||
* No code is recoverable from here — sign in with the account password.
|
||||
*/
|
||||
{ type: "needsPassword" }
|
||||
/**
|
||||
* User information
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<!--
|
||||
Numeric PIN entry.
|
||||
|
||||
Presentation only: it collects digits and hands them up. It does not know the
|
||||
PIN, does not compare anything, and does not count attempts — the backend
|
||||
returns an `UnlockOutcome` and this renders whatever it says. A pad that could
|
||||
decide would be a lock the webview could pick.
|
||||
|
||||
Sized for a living room: large targets, usable with a remote's arrow keys as
|
||||
well as a touchscreen.
|
||||
|
||||
TRACES: UR-083 | DR-276
|
||||
-->
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
/** Digits entered so far. */
|
||||
value: string;
|
||||
/** Message under the dots — wrong PIN, lockout, etc. */
|
||||
error?: string | null;
|
||||
/** Blocks input while an attempt is in flight or the profile is locked out. */
|
||||
disabled?: boolean;
|
||||
maxLength?: number;
|
||||
onsubmit: (pin: string) => void;
|
||||
oncancel: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(),
|
||||
error = null,
|
||||
disabled = false,
|
||||
maxLength = 8,
|
||||
onsubmit,
|
||||
oncancel,
|
||||
}: Props = $props();
|
||||
|
||||
const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "", "0", "⌫"];
|
||||
|
||||
function press(key: string) {
|
||||
if (disabled) return;
|
||||
if (key === "⌫") {
|
||||
value = value.slice(0, -1);
|
||||
} else if (key && value.length < maxLength) {
|
||||
value = value + key;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (disabled) return;
|
||||
if (/^[0-9]$/.test(event.key)) {
|
||||
event.preventDefault();
|
||||
press(event.key);
|
||||
} else if (event.key === "Backspace") {
|
||||
event.preventDefault();
|
||||
press("⌫");
|
||||
} else if (event.key === "Enter" && value.length >= 4) {
|
||||
event.preventDefault();
|
||||
onsubmit(value);
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
oncancel();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="flex flex-col items-center gap-6">
|
||||
<!-- Entered digits, shown as dots. -->
|
||||
<div class="flex gap-3 h-4 items-center" aria-live="polite" aria-label="PIN entry">
|
||||
{#each Array(Math.max(value.length, 4)) as _, i (i)}
|
||||
<div
|
||||
class="rounded-full transition-all {i < value.length
|
||||
? 'w-3 h-3 bg-white'
|
||||
: 'w-3 h-3 bg-gray-600'}"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-red-400 text-sm text-center max-w-xs" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
{#each KEYS as key (key)}
|
||||
{#if key === ""}
|
||||
<div></div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => press(key)}
|
||||
{disabled}
|
||||
class="w-18 h-18 min-w-[4.5rem] min-h-[4.5rem] rounded-full bg-[var(--color-surface)] hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-[var(--color-jellyfin)] disabled:opacity-40 disabled:cursor-not-allowed text-2xl font-light transition-colors"
|
||||
aria-label={key === "⌫" ? "Delete" : key}
|
||||
>
|
||||
{key}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 w-full max-w-xs">
|
||||
<button
|
||||
type="button"
|
||||
onclick={oncancel}
|
||||
class="flex-1 py-3 rounded-lg border border-gray-700 hover:bg-[var(--color-surface)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onsubmit(value)}
|
||||
disabled={disabled || value.length < 4}
|
||||
class="flex-1 py-3 rounded-lg bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors"
|
||||
>
|
||||
Unlock
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4,7 +4,7 @@
|
||||
Settings, Display) and Sign out. Available on every authenticated,
|
||||
non-immersive screen via the shared AppHeader.
|
||||
|
||||
TRACES: UR-054 | DR-075
|
||||
TRACES: UR-054, UR-082 | DR-075, DR-276
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
@@ -105,6 +105,32 @@
|
||||
|
||||
<div class="border-t border-gray-700 my-1"></div>
|
||||
|
||||
<!--
|
||||
Grouped with the identity block above rather than with the destinations
|
||||
below: this changes *who* the app is, not where it goes. Shown even on a
|
||||
device with one account, because the picker is also where a second one is
|
||||
added — hiding it until a second profile exists would leave no way in
|
||||
from here. (DR-276)
|
||||
-->
|
||||
<a
|
||||
href="/profiles"
|
||||
role="menuitem"
|
||||
class="flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
|
||||
onclick={() => close(false)}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
Switch profile
|
||||
</a>
|
||||
|
||||
<div class="border-t border-gray-700 my-1"></div>
|
||||
|
||||
<a
|
||||
href="/downloads"
|
||||
role="menuitem"
|
||||
|
||||
@@ -73,7 +73,22 @@ describe("AccountMenu", () => {
|
||||
render(AccountMenu);
|
||||
openMenu();
|
||||
const items = screen.getAllByRole("menuitem").map((el) => el.textContent?.trim());
|
||||
expect(items).toEqual(["Downloads", "Settings", "Display", "Sign out"]);
|
||||
expect(items).toEqual(["Switch profile", "Downloads", "Settings", "Display", "Sign out"]);
|
||||
});
|
||||
|
||||
/**
|
||||
* "Switch profile" sits directly under the identity block, before the
|
||||
* destinations. It answers "who is this?", not "where do I go?", and burying
|
||||
* it in Settings is what this entry exists to undo.
|
||||
*
|
||||
* TRACES: UR-082 | DR-276
|
||||
*/
|
||||
it("puts Switch profile first, next to the identity block", async () => {
|
||||
render(AccountMenu);
|
||||
openMenu();
|
||||
const items = screen.getAllByRole("menuitem");
|
||||
expect(items[0].textContent?.trim()).toBe("Switch profile");
|
||||
expect(items[0].getAttribute("href")).toBe("/profiles");
|
||||
});
|
||||
|
||||
it("shows the identity block with name and server host", async () => {
|
||||
|
||||
@@ -80,8 +80,13 @@
|
||||
shouldExitBackgroundAudio,
|
||||
shouldResumeOnForeground,
|
||||
planHandoffReturn,
|
||||
setBackgroundAudioArmed,
|
||||
enteringPictureInPicture,
|
||||
inPictureInPicture,
|
||||
type BackgroundAudioState,
|
||||
type BackgroundBehaviour,
|
||||
} from "./backgroundAudioHandoff";
|
||||
import { shouldApplyTimeUpdate } from "./timeTracking";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
import { elementSrcFor, loaderForTransport } from "$lib/player/streamTransport";
|
||||
|
||||
@@ -1350,14 +1355,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Update time on timeupdate event (for when RAF isn't running)
|
||||
// Second position source, alongside the RAF loop. It used to exclude itself
|
||||
// whenever the video was playing, on the theory that RAF had it covered --
|
||||
// but RAF only runs while the document is rendered, and an Android activity
|
||||
// behind a PiP window is paused. `currentTime` then froze at the moment PiP
|
||||
// was entered while the element played on, and every consumer of it froze
|
||||
// too: the seek bar, the progress reports, the position mirrored into Rust,
|
||||
// and -- the visible symptom -- the background-audio handoff, which resumed
|
||||
// the audio-only stream back at the PiP-entry position. (DR-265)
|
||||
function handleTimeUpdate() {
|
||||
if (videoElement && !isSeeking && !isDraggingSeekBar && !isPlaying) {
|
||||
const newCurrentTime = seekOffset + videoElement.currentTime;
|
||||
if (videoElement.readyState >= 2) {
|
||||
currentTime = newCurrentTime;
|
||||
}
|
||||
if (!videoElement) return;
|
||||
if (
|
||||
!shouldApplyTimeUpdate({
|
||||
isPlaying,
|
||||
isSeeking,
|
||||
isDraggingSeekBar,
|
||||
readyState: videoElement.readyState,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
currentTime = seekOffset + videoElement.currentTime;
|
||||
}
|
||||
|
||||
function handleLoadedMetadata() {
|
||||
@@ -1833,6 +1851,11 @@
|
||||
const pipSupported = isPipSupported();
|
||||
|
||||
function handlePictureInPicture() {
|
||||
// Pressing PiP is an unambiguous request to keep the picture, so it disarms
|
||||
// the behaviour that throws the picture away. Exclusivity was previously
|
||||
// enforced only from the toggle's side (it suppressed *auto*-PiP), leaving
|
||||
// this button able to arm both at once. (DR-266)
|
||||
applyBackgroundBehaviour(enteringPictureInPicture(backgroundBehaviour()));
|
||||
enterPip();
|
||||
}
|
||||
|
||||
@@ -1855,16 +1878,26 @@
|
||||
// what we stopped -- never something the user paused themselves.
|
||||
let pausedByBackgrounding = false;
|
||||
|
||||
function toggleBackgroundAudio() {
|
||||
backgroundAudioOn = !backgroundAudioOn;
|
||||
/** The pair of background behaviours as they currently stand. */
|
||||
function backgroundBehaviour(): BackgroundBehaviour {
|
||||
return setBackgroundAudioArmed(backgroundAudioOn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a background-behaviour pair to both natives, so exactly one is armed.
|
||||
*/
|
||||
function applyBackgroundBehaviour(next: BackgroundBehaviour) {
|
||||
backgroundAudioOn = next.backgroundAudioArmed;
|
||||
log.debug("Background-audio toggle ->", backgroundAudioOn);
|
||||
// Arm/disarm native background-audio mode AND flip auto-PiP the other way,
|
||||
// so exactly one background behavior is active.
|
||||
const armed = setBackgroundAudioEnabled(backgroundAudioOn);
|
||||
if (!armed) {
|
||||
const armed = setBackgroundAudioEnabled(next.backgroundAudioArmed);
|
||||
if (!armed && next.backgroundAudioArmed) {
|
||||
log.warn("Background audio NOT armed natively (no bridge)");
|
||||
}
|
||||
setAutoEnterEnabled(!backgroundAudioOn);
|
||||
setAutoEnterEnabled(next.autoPipEnabled);
|
||||
}
|
||||
|
||||
function toggleBackgroundAudio() {
|
||||
applyBackgroundBehaviour(setBackgroundAudioArmed(!backgroundAudioOn));
|
||||
}
|
||||
|
||||
// App went to background/locked while background-audio is armed: hand off to
|
||||
@@ -1879,7 +1912,15 @@
|
||||
try {
|
||||
action = await commands.playerBackgroundAction(
|
||||
signal.backgroundAudioArmed,
|
||||
signal.inPictureInPicture,
|
||||
// Not `signal.inPictureInPicture` alone. That is one sample of
|
||||
// `isInPictureInPictureMode`, taken inside onStop(); there are
|
||||
// orderings -- the keyguard dismissing the window, the window being
|
||||
// stashed, OEM variance in when onPictureInPictureModeChanged(false)
|
||||
// lands -- where it reads false with the window still on screen, and
|
||||
// the video the user is watching is handed off to audio. `isInPip` is
|
||||
// a latch over the pip-entered/exited events, which arrive on the same
|
||||
// queue ahead of this one. (DR-266)
|
||||
inPictureInPicture(signal.inPictureInPicture, isInPip),
|
||||
);
|
||||
} catch (e) {
|
||||
// Never leave playback in an undefined state because a decision call
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
shouldExitBackgroundAudio,
|
||||
shouldResumeOnForeground,
|
||||
type BackgroundAudioState,
|
||||
setBackgroundAudioArmed,
|
||||
enteringPictureInPicture,
|
||||
inPictureInPicture,
|
||||
} from "./backgroundAudioHandoff";
|
||||
|
||||
// TRACES: UR-040 | DR-052 | UT-060
|
||||
@@ -137,3 +140,69 @@ describe("backgroundAudioHandoff", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* TRACES: UT-246 | DR-266
|
||||
*/
|
||||
describe("background behaviour exclusivity", () => {
|
||||
describe("setBackgroundAudioArmed", () => {
|
||||
it("disables auto-PiP when background audio is armed", () => {
|
||||
expect(setBackgroundAudioArmed(true)).toEqual({
|
||||
backgroundAudioArmed: true,
|
||||
autoPipEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("restores auto-PiP when background audio is disarmed", () => {
|
||||
expect(setBackgroundAudioArmed(false)).toEqual({
|
||||
backgroundAudioArmed: false,
|
||||
autoPipEnabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("enteringPictureInPicture", () => {
|
||||
it("disarms background audio when the user opens a PiP window", () => {
|
||||
// THE REPORTED BUG, half one. Exclusivity was enforced in one direction
|
||||
// only: arming the toggle suppressed auto-PiP, but the PiP *button* was
|
||||
// still offered and still worked, leaving both behaviours live at once.
|
||||
// A single stray background signal then handed a video the user was
|
||||
// watching in a PiP window off to audio-only.
|
||||
expect(
|
||||
enteringPictureInPicture({ backgroundAudioArmed: true, autoPipEnabled: false }),
|
||||
).toEqual({ backgroundAudioArmed: false, autoPipEnabled: true });
|
||||
});
|
||||
|
||||
it("leaves an already-exclusive state alone", () => {
|
||||
const state = { backgroundAudioArmed: false, autoPipEnabled: true };
|
||||
expect(enteringPictureInPicture(state)).toEqual(state);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inPictureInPicture", () => {
|
||||
it("trusts the native flag when the two agree", () => {
|
||||
expect(inPictureInPicture(true, true)).toBe(true);
|
||||
expect(inPictureInPicture(false, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a live PiP window as PiP even when the native flag says otherwise", () => {
|
||||
// THE REPORTED BUG, half two. `isInPictureInPictureMode` is sampled once,
|
||||
// inside onStop(). There are orderings -- the keyguard dismissing the
|
||||
// window, the window being stashed, OEM variance in whether
|
||||
// onPictureInPictureModeChanged(false) lands first -- where the activity
|
||||
// is stopped with a PiP window still on screen and that single boolean
|
||||
// reads false. Backgrounding then means "the app is gone" and the video
|
||||
// the user is watching is handed off to audio.
|
||||
expect(inPictureInPicture(false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not resurrect a window the frontend has already seen close", () => {
|
||||
// jellytau-pip-exited and jellytau-background are both posted to the same
|
||||
// WebView message queue, in that order, so a genuine exit is always known
|
||||
// by the time the background signal is handled. Leaving playback running
|
||||
// here would be the opposite defect: audio continuing after the user
|
||||
// closed the window and left the app.
|
||||
expect(inPictureInPicture(false, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,3 +121,61 @@ export function planHandoffReturn(opts: {
|
||||
shouldPlay: shouldResumeOnForeground(opts.wasPlaying, opts.nativeStateKind),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the two mutually exclusive background behaviours is armed.
|
||||
*
|
||||
* TRACES: UR-040, UR-041 | DR-266 | UT-246
|
||||
*
|
||||
* Backgrounding the app can either shrink the video into a picture-in-picture
|
||||
* window (UR-041) or hand its audio off to the native player and drop the
|
||||
* picture (UR-040). They are alternatives — the first keeps the video on
|
||||
* screen, the second throws it away — so at most one may ever be armed.
|
||||
*/
|
||||
export interface BackgroundBehaviour {
|
||||
/** The per-player background-audio toggle (UR-040). */
|
||||
backgroundAudioArmed: boolean;
|
||||
/** Whether leaving the app auto-enters PiP (UR-041). */
|
||||
autoPipEnabled: boolean;
|
||||
}
|
||||
|
||||
/** Arming/disarming the background-audio toggle flips auto-PiP the other way. */
|
||||
export function setBackgroundAudioArmed(armed: boolean): BackgroundBehaviour {
|
||||
return { backgroundAudioArmed: armed, autoPipEnabled: !armed };
|
||||
}
|
||||
|
||||
/**
|
||||
* The user has asked for a PiP window, by pressing the button rather than by
|
||||
* leaving the app.
|
||||
*
|
||||
* Exclusivity used to be enforced from one side only — arming the toggle
|
||||
* suppressed auto-PiP — while the PiP button stayed live and ungated. Pressing
|
||||
* it left both behaviours armed, and the video was then one stray background
|
||||
* signal away from being handed off to audio-only while the user was watching
|
||||
* it in the window. Pressing PiP is an unambiguous request to keep the picture,
|
||||
* so it disarms the behaviour that throws the picture away.
|
||||
*/
|
||||
export function enteringPictureInPicture(_current: BackgroundBehaviour): BackgroundBehaviour {
|
||||
return setBackgroundAudioArmed(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the app is in a picture-in-picture window, for the purpose of
|
||||
* deciding what backgrounding means.
|
||||
*
|
||||
* TRACES: UR-040, UR-041 | DR-266 | UT-246
|
||||
*
|
||||
* @param nativeFlag the Activity's `isInPictureInPictureMode`, sampled inside
|
||||
* `onStop()`
|
||||
* @param sawPipEntered whether the frontend has seen `jellytau-pip-entered`
|
||||
* without a matching `jellytau-pip-exited`
|
||||
*/
|
||||
export function inPictureInPicture(nativeFlag: boolean, sawPipEntered: boolean): boolean {
|
||||
// Either witness is enough. The native flag is a single sample taken inside
|
||||
// onStop(); the frontend's is a latch, set by `jellytau-pip-entered` and
|
||||
// cleared by `jellytau-pip-exited`. Both events reach the WebView through the
|
||||
// same message queue in dispatch order, so a genuine exit is always known
|
||||
// before the background signal that follows it — the latch can report a
|
||||
// window that is still open, never one that has closed.
|
||||
return nativeFlag || sawPipEntered;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { shouldApplyTimeUpdate } from "./timeTracking";
|
||||
|
||||
/**
|
||||
* TRACES: UT-245 | DR-265
|
||||
*/
|
||||
describe("shouldApplyTimeUpdate", () => {
|
||||
const base = { isPlaying: false, isSeeking: false, isDraggingSeekBar: false, readyState: 4 };
|
||||
|
||||
it("applies the update while the video is PLAYING", () => {
|
||||
// THE REPORTED BUG. `timeupdate` was the only position source that still
|
||||
// fires once requestAnimationFrame stops -- which is exactly what happens
|
||||
// when the activity is paused behind a picture-in-picture window. Gating it
|
||||
// on `!isPlaying` disabled it precisely when it was the only thing left,
|
||||
// so the component's `currentTime` froze at the moment PiP was entered
|
||||
// while the element played on. The background-audio handoff then resumed
|
||||
// the audio-only stream at that frozen position.
|
||||
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("still applies the update while paused", () => {
|
||||
// The case it always handled: RAF is stopped, timeupdate carries the seek.
|
||||
expect(shouldApplyTimeUpdate(base)).toBe(true);
|
||||
});
|
||||
|
||||
it("yields to an in-flight seek", () => {
|
||||
// A seek owns the position until it settles; a stale element read landing
|
||||
// mid-seek is what makes a scrubbed video snap back.
|
||||
expect(shouldApplyTimeUpdate({ ...base, isSeeking: true })).toBe(false);
|
||||
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, isSeeking: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("yields while the user is dragging the seek bar", () => {
|
||||
expect(shouldApplyTimeUpdate({ ...base, isDraggingSeekBar: true })).toBe(false);
|
||||
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, isDraggingSeekBar: true })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores an element with no usable data yet", () => {
|
||||
// readyState < HAVE_CURRENT_DATA reads 0, which would rewind the position.
|
||||
expect(shouldApplyTimeUpdate({ ...base, readyState: 1 })).toBe(false);
|
||||
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, readyState: 0 })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Pure helpers for keeping the player's position variable honest.
|
||||
*
|
||||
* TRACES: UR-004, UR-041 | DR-265 | UT-245
|
||||
*
|
||||
* `VideoPlayer.svelte` tracks the absolute playback position in its own
|
||||
* `currentTime` variable rather than reading `videoElement.currentTime` at the
|
||||
* point of use — transcoded HLS resets the element to 0 on every segment
|
||||
* rebuild, so only the component's running total is meaningful. Everything
|
||||
* downstream reads that variable: the seek bar, the progress reports, the
|
||||
* position mirrored into Rust, and the background-audio handoff.
|
||||
*
|
||||
* Which makes "who is allowed to write it" a correctness question, not a
|
||||
* rendering detail — hence a pure module with tests rather than a condition
|
||||
* buried in an event handler.
|
||||
*/
|
||||
|
||||
export interface TimeUpdateGate {
|
||||
/**
|
||||
* Deliberately does NOT gate the update, and is accepted only to say so.
|
||||
*
|
||||
* `timeupdate` was written as a fallback "for when RAF isn't running" and so
|
||||
* excluded itself whenever `isPlaying` was true. But RAF is driven by the
|
||||
* document being rendered, and an Android activity behind a picture-in-picture
|
||||
* window is paused: the loop stops while the element plays on, and the one
|
||||
* remaining position source had switched itself off. Both writing the same
|
||||
* derived value costs nothing — the element is the authority either way.
|
||||
*/
|
||||
isPlaying?: boolean;
|
||||
isSeeking: boolean;
|
||||
isDraggingSeekBar: boolean;
|
||||
readyState: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `timeupdate` event may write the component's position.
|
||||
*
|
||||
* Kept free of Svelte/DOM so the rule is unit-testable without mounting the
|
||||
* player.
|
||||
*/
|
||||
export function shouldApplyTimeUpdate(opts: TimeUpdateGate): boolean {
|
||||
// An in-flight seek or a drag owns the position until it settles, and an
|
||||
// element with no current data reads 0, which would rewind it.
|
||||
return !opts.isSeeking && !opts.isDraggingSeekBar && opts.readyState >= 2;
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
<!--
|
||||
PIN management for the profile you are signed in as.
|
||||
|
||||
Deliberately scoped to the *active* profile. Letting a signed-in session set a
|
||||
PIN on someone else's profile would be an escalation path with no real use —
|
||||
a PIN-less profile could be locked by anyone standing at the device, and the
|
||||
owner would be pushed down the password route to get back into their own
|
||||
account. Other profiles are listed read-only so a parent can see at a glance
|
||||
which are protected; adding and removing them lives on the picker.
|
||||
|
||||
Nothing here compares a PIN or counts an attempt. The form validates shape so
|
||||
it can say what is wrong before submitting; Rust validates again and is the
|
||||
only thing that ever verifies one.
|
||||
|
||||
TRACES: UR-082, UR-083 | DR-268, DR-276
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { profiles } from "$lib/stores/profiles";
|
||||
import {
|
||||
validatePinForm,
|
||||
pinActionLabel,
|
||||
PIN_MAX_LENGTH,
|
||||
type PinIntent,
|
||||
} from "$lib/utils/pinForm";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("ProfileSecurity");
|
||||
|
||||
let intent = $state<PinIntent | null>(null);
|
||||
let currentPin = $state("");
|
||||
let newPin = $state("");
|
||||
let confirmPin = $state("");
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let notice = $state<string | null>(null);
|
||||
|
||||
const active = $derived($profiles.profiles.find((p) => p.isActive) ?? null);
|
||||
const others = $derived($profiles.profiles.filter((p) => !p.isActive));
|
||||
const hasPin = $derived(active?.unlockMethod === "pin");
|
||||
|
||||
const validationError = $derived(
|
||||
intent === null ? null : validatePinForm({ intent, hasPin, currentPin, newPin, confirmPin }),
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
void profiles.refresh();
|
||||
});
|
||||
|
||||
function begin(next: PinIntent) {
|
||||
intent = next;
|
||||
currentPin = "";
|
||||
newPin = "";
|
||||
confirmPin = "";
|
||||
error = null;
|
||||
notice = null;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
intent = null;
|
||||
currentPin = "";
|
||||
newPin = "";
|
||||
confirmPin = "";
|
||||
error = null;
|
||||
}
|
||||
|
||||
async function submit(event: Event) {
|
||||
event.preventDefault();
|
||||
if (!active || intent === null || validationError) return;
|
||||
|
||||
busy = true;
|
||||
error = null;
|
||||
try {
|
||||
await profiles.setPin(
|
||||
active.userId,
|
||||
hasPin ? currentPin : null,
|
||||
intent === "clear" ? null : newPin,
|
||||
);
|
||||
notice =
|
||||
intent === "clear" ? "PIN turned off. This profile now opens with one tap." : "PIN saved.";
|
||||
cancel();
|
||||
} catch (e) {
|
||||
log.error("Could not update PIN:", e);
|
||||
error = e instanceof Error ? e.message : "Could not update the PIN";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-5">
|
||||
{#if !active}
|
||||
<p class="text-sm text-gray-400">No profile is signed in.</p>
|
||||
{:else}
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
PIN for {active.username}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-400">
|
||||
{#if hasPin}
|
||||
This profile asks for a PIN before anyone can switch to it.
|
||||
{:else}
|
||||
This profile opens with one tap — right for a child's account, and what you want unless
|
||||
there is something to keep out.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="shrink-0 text-xs px-2 py-1 rounded-full {hasPin
|
||||
? 'bg-green-900/60 text-green-300'
|
||||
: 'bg-gray-700 text-gray-300'}"
|
||||
>
|
||||
{hasPin ? "On" : "Off"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if notice}
|
||||
<p class="text-sm text-green-400">{notice}</p>
|
||||
{/if}
|
||||
|
||||
{#if intent === null}
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#if hasPin}
|
||||
<button
|
||||
onclick={() => begin("change")}
|
||||
class="px-4 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Change PIN
|
||||
</button>
|
||||
<button
|
||||
onclick={() => begin("clear")}
|
||||
class="px-4 py-2 border border-gray-600 hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
Turn off PIN
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => begin("set")}
|
||||
class="px-4 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Set a PIN
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={submit} class="space-y-4 max-w-sm">
|
||||
{#if hasPin}
|
||||
<div>
|
||||
<label for="current-pin" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Current PIN
|
||||
</label>
|
||||
<input
|
||||
id="current-pin"
|
||||
type="password"
|
||||
inputmode="numeric"
|
||||
maxlength={PIN_MAX_LENGTH}
|
||||
bind:value={currentPin}
|
||||
disabled={busy}
|
||||
class="w-full px-4 py-3 bg-[var(--color-bg)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if intent !== "clear"}
|
||||
<div>
|
||||
<label for="new-pin" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
New PIN (4–8 digits)
|
||||
</label>
|
||||
<input
|
||||
id="new-pin"
|
||||
type="password"
|
||||
inputmode="numeric"
|
||||
maxlength={PIN_MAX_LENGTH}
|
||||
bind:value={newPin}
|
||||
disabled={busy}
|
||||
class="w-full px-4 py-3 bg-[var(--color-bg)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirm-pin" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Confirm new PIN
|
||||
</label>
|
||||
<input
|
||||
id="confirm-pin"
|
||||
type="password"
|
||||
inputmode="numeric"
|
||||
maxlength={PIN_MAX_LENGTH}
|
||||
bind:value={confirmPin}
|
||||
disabled={busy}
|
||||
class="w-full px-4 py-3 bg-[var(--color-bg)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-400">
|
||||
Turning the PIN off means anyone using this device can switch to
|
||||
{active.username} in one tap.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
{:else if validationError && (currentPin || newPin || confirmPin)}
|
||||
<p class="text-sm text-amber-400">{validationError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancel}
|
||||
disabled={busy}
|
||||
class="flex-1 py-3 rounded-lg border border-gray-600 hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || validationError !== null}
|
||||
class="flex-1 py-3 rounded-lg bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors"
|
||||
>
|
||||
{pinActionLabel(intent)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if others.length > 0}
|
||||
<div class="border-t border-gray-700 pt-5">
|
||||
<h3 class="text-sm font-semibold text-white mb-3">Other profiles on this device</h3>
|
||||
<ul class="space-y-2">
|
||||
{#each others as profile (profile.userId)}
|
||||
<li class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-300">{profile.username}</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{profile.unlockMethod === "pin" ? "PIN" : "No PIN"}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="text-xs text-gray-500 mt-3">
|
||||
A profile's PIN can only be changed from that profile. Sign in as them to set one.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="border-t border-gray-700 pt-5">
|
||||
<button
|
||||
onclick={() => goto("/profiles?manage=1")}
|
||||
class="text-sm text-[var(--color-jellyfin)] hover:underline"
|
||||
>
|
||||
Add or remove profiles
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -606,6 +606,66 @@ function createAuthStore() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild this store's view of the world after the backend has switched
|
||||
* profiles.
|
||||
*
|
||||
* The backend already flipped the active user, adopted the new session and
|
||||
* destroyed the old repository handle — in that order, which is the part that
|
||||
* matters. What is left is the half only the frontend owns: a `RepositoryClient`
|
||||
* bound to the new session, and the player's reporting configuration.
|
||||
*
|
||||
* Deliberately *not* a login: no password is involved and no token is minted,
|
||||
* because switching must leave both profiles able to come back with one tap.
|
||||
*
|
||||
* TRACES: UR-082 | DR-270
|
||||
*/
|
||||
async function adoptSwitchedSession() {
|
||||
const session = await commands.authGetSession();
|
||||
if (!session) throw new Error("No session after profile switch");
|
||||
|
||||
if (repository) {
|
||||
try {
|
||||
await repository.destroy();
|
||||
} catch (error) {
|
||||
log.error("Failed to destroy repository during switch:", error);
|
||||
}
|
||||
}
|
||||
|
||||
repository = new RepositoryClient();
|
||||
await repository.create(
|
||||
session.serverUrl,
|
||||
session.userId,
|
||||
session.accessToken,
|
||||
session.serverId,
|
||||
);
|
||||
|
||||
try {
|
||||
const deviceId = await getDeviceId();
|
||||
await commands.playerConfigureJellyfin(
|
||||
session.serverUrl,
|
||||
session.accessToken,
|
||||
session.userId,
|
||||
deviceId,
|
||||
);
|
||||
} catch (error) {
|
||||
log.error("Failed to reconfigure player after switch:", error);
|
||||
}
|
||||
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: { id: session.userId, name: session.username } as User,
|
||||
serverUrl: session.serverUrl,
|
||||
serverName: session.serverName,
|
||||
error: null,
|
||||
securityWarning: null,
|
||||
needsReauth: false,
|
||||
isVerifying: false,
|
||||
sessionVerified: session.verified,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
initialize,
|
||||
@@ -620,6 +680,7 @@ function createAuthStore() {
|
||||
getUserId,
|
||||
getServerUrl,
|
||||
retryVerification,
|
||||
adoptSwitchedSession,
|
||||
cleanupEventListeners,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Profile switching — a thin wrapper over the Rust `profiles_*` commands.
|
||||
*
|
||||
* There is deliberately no logic here worth the name. This store does not
|
||||
* compare a PIN, count an attempt, decide whether a profile is locked, or work
|
||||
* out whether the picker should appear at startup. All of that is backend state
|
||||
* and arrives as an opaque `unlockMethod`, an `UnlockOutcome` or a
|
||||
* `StartupTarget`. A store that re-derived any of it would be a gate the webview
|
||||
* could skip.
|
||||
*
|
||||
* TRACES: UR-082, UR-083, UR-084 | DR-267, DR-269, DR-274, DR-276
|
||||
*/
|
||||
|
||||
import { writable, get } from "svelte/store";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import type { Profile, StartupTarget, UnlockOutcome } from "$lib/api/bindings";
|
||||
import { getDeviceId } from "$lib/services/deviceId";
|
||||
import { auth } from "./auth";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("Profiles");
|
||||
|
||||
interface ProfilesState {
|
||||
profiles: Profile[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function createProfilesStore() {
|
||||
const { subscribe, set, update } = writable<ProfilesState>({
|
||||
profiles: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
async function refresh(): Promise<Profile[]> {
|
||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||
try {
|
||||
const profiles = await commands.profilesList();
|
||||
set({ profiles, isLoading: false, error: null });
|
||||
return profiles;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error("Failed to list profiles:", error);
|
||||
set({ profiles: [], isLoading: false, error: message });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the app should go on launch. Asked, never computed — the answer
|
||||
* depends on PIN presence and a stored setting, neither of which the frontend
|
||||
* should be reasoning about.
|
||||
*/
|
||||
async function startupTarget(): Promise<StartupTarget> {
|
||||
return await commands.profilesStartupTarget();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter a profile. On success the backend has already switched; this only
|
||||
* rebuilds the frontend's repository handle.
|
||||
*/
|
||||
async function unlock(userId: string, pin: string | null): Promise<UnlockOutcome> {
|
||||
const outcome = await commands.profilesUnlock(userId, pin);
|
||||
if (outcome.type === "ok") {
|
||||
await auth.adoptSwitchedSession();
|
||||
await refresh();
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* The way back in for someone who has forgotten their PIN. Their ordinary
|
||||
* Jellyfin password is the authority over their own account, so there is
|
||||
* nothing else to reset.
|
||||
*/
|
||||
async function unlockWithPassword(userId: string, password: string): Promise<UnlockOutcome> {
|
||||
const deviceId = await getDeviceId();
|
||||
const outcome = await commands.profilesUnlockWithPassword(userId, password, deviceId);
|
||||
if (outcome.type === "ok") {
|
||||
await auth.adoptSwitchedSession();
|
||||
await refresh();
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/** Add another account from the server already connected. */
|
||||
async function add(username: string, password: string, pin: string | null): Promise<Profile> {
|
||||
const deviceId = await getDeviceId();
|
||||
const profile = await commands.profilesAdd(username, password, pin, deviceId);
|
||||
await refresh();
|
||||
return profile;
|
||||
}
|
||||
|
||||
async function setPin(userId: string, currentPin: string | null, newPin: string | null) {
|
||||
await commands.profilesSetPin(userId, currentPin, newPin);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function remove(userId: string) {
|
||||
await commands.profilesRemove(userId);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function setAskOnStart(enabled: boolean) {
|
||||
await commands.profilesSetAskOnStart(enabled);
|
||||
}
|
||||
|
||||
/** Whether this device has more than one account — what makes the UI worth showing at all. */
|
||||
function isShared(): boolean {
|
||||
return get({ subscribe }).profiles.length > 1;
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
refresh,
|
||||
startupTarget,
|
||||
unlock,
|
||||
unlockWithPassword,
|
||||
add,
|
||||
setPin,
|
||||
remove,
|
||||
setAskOnStart,
|
||||
isShared,
|
||||
};
|
||||
}
|
||||
|
||||
export const profiles = createProfilesStore();
|
||||
@@ -202,3 +202,37 @@ describe("showHeaderSearch", () => {
|
||||
expect(showHeaderSearch({ pathname: "/settings" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The profile picker is a gate, not a page. It must be chrome-free for the same
|
||||
* reason /login is: bottom nav over a "who's watching" screen lets someone tab
|
||||
* straight past the profile they were being asked to choose, and an in-app mini
|
||||
* player on it would offer a route into the previous profile's queue. OS
|
||||
* lockscreen transport controls are unaffected — those are what keep playing
|
||||
* audio controllable while the app is locked (DR-275).
|
||||
*
|
||||
* TRACES: UR-082 | DR-276
|
||||
*/
|
||||
describe("profile picker chrome", () => {
|
||||
const pathname = "/profiles";
|
||||
|
||||
it("shows no bottom nav, even authenticated", () => {
|
||||
expect(showBottomNav({ pathname, isAuthenticated: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("shows no global mini player", () => {
|
||||
expect(showGlobalMiniPlayer({ pathname })).toBe(false);
|
||||
});
|
||||
|
||||
it("shows no global header", () => {
|
||||
expect(showGlobalHeader({ pathname, isAuthenticated: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("owns its own layout", () => {
|
||||
expect(routeOwnsLayout({ pathname })).toBe(true);
|
||||
});
|
||||
|
||||
it("lets the shell reserve the bottom inset, since nothing else will", () => {
|
||||
expect(shellReservesBottomInset({ pathname, isAuthenticated: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,10 +28,19 @@ export interface BottomUiVisibilityInput {
|
||||
|
||||
/**
|
||||
* The bottom nav is shown on every authenticated route except the full-screen
|
||||
* player and the login route.
|
||||
* player, the login route, and the profile picker.
|
||||
*
|
||||
* `/profiles` is grouped with `/login` throughout this module because it is a
|
||||
* gate rather than a page: a nav bar over "who's watching" lets someone tab
|
||||
* straight past the choice they were being asked to make.
|
||||
*/
|
||||
export function showBottomNav({ pathname, isAuthenticated }: BottomUiVisibilityInput): boolean {
|
||||
return isAuthenticated && !pathname.startsWith("/player/") && !pathname.startsWith("/login");
|
||||
return (
|
||||
isAuthenticated &&
|
||||
!pathname.startsWith("/player/") &&
|
||||
!pathname.startsWith("/login") &&
|
||||
!pathname.startsWith("/profiles")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,6 +53,7 @@ export function showGlobalMiniPlayer({ pathname }: { pathname: string }): boolea
|
||||
return (
|
||||
!pathname.startsWith("/player/") &&
|
||||
!pathname.startsWith("/login") &&
|
||||
!pathname.startsWith("/profiles") &&
|
||||
!pathname.startsWith("/settings")
|
||||
);
|
||||
}
|
||||
@@ -58,7 +68,8 @@ export function routeOwnsLayout({ pathname }: { pathname: string }): boolean {
|
||||
return (
|
||||
pathname.startsWith("/library") ||
|
||||
pathname.startsWith("/player/") ||
|
||||
pathname.startsWith("/login")
|
||||
pathname.startsWith("/login") ||
|
||||
pathname.startsWith("/profiles")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,7 +80,7 @@ export function routeOwnsLayout({ pathname }: { pathname: string }): boolean {
|
||||
* Routes that own their layout (library) render their own AppHeader, so the
|
||||
* root must not double it up. `/settings` owns its content but deliberately has
|
||||
* no account menu (the user is already there). `/player/*` and `/login` are
|
||||
* immersive/chrome-free. Everything else authenticated (`/`, `/search`,
|
||||
* immersive/chrome-free, as is `/profiles`. Everything else authenticated (`/`, `/search`,
|
||||
* `/downloads`) gets the header from the root — the whole point of UR-054.
|
||||
*
|
||||
* TRACES: UR-054 | DR-076
|
||||
@@ -109,7 +120,8 @@ export function showBottomUi(input: BottomUiVisibilityInput): boolean {
|
||||
* Exactly one element may reserve it. BottomUi owns it whenever it renders,
|
||||
* because the padding belongs *inside* its surface box so the colour extends
|
||||
* behind the bar rather than leaving a strip of page background. On routes with
|
||||
* no bottom UI at all (login, the full-screen player) nothing else would, so
|
||||
* no bottom UI at all (login, the profile picker, the full-screen player)
|
||||
* nothing else would, so
|
||||
* the shell takes it.
|
||||
*
|
||||
* TRACES: UR-066 | DR-112
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { validatePinForm, pinActionLabel, type PinFormState } from "./pinForm";
|
||||
|
||||
function form(overrides: Partial<PinFormState> = {}): PinFormState {
|
||||
return {
|
||||
intent: "set",
|
||||
hasPin: false,
|
||||
currentPin: "",
|
||||
newPin: "",
|
||||
confirmPin: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("validatePinForm", () => {
|
||||
it("accepts a well-formed new PIN", () => {
|
||||
expect(validatePinForm(form({ newPin: "1234", confirmPin: "1234" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("requires the current PIN whenever one is set", () => {
|
||||
expect(
|
||||
validatePinForm(form({ intent: "change", hasPin: true, newPin: "5678", confirmPin: "5678" })),
|
||||
).toBe("Enter your current PIN.");
|
||||
});
|
||||
|
||||
it("does not ask for a current PIN when none is set", () => {
|
||||
expect(validatePinForm(form({ hasPin: false, newPin: "1234", confirmPin: "1234" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects non-digits", () => {
|
||||
expect(validatePinForm(form({ newPin: "12a4", confirmPin: "12a4" }))).toBe(
|
||||
"A PIN can only contain digits.",
|
||||
);
|
||||
});
|
||||
|
||||
it("enforces the length range at both ends", () => {
|
||||
expect(validatePinForm(form({ newPin: "123", confirmPin: "123" }))).toBe(
|
||||
"A PIN must be 4–8 digits.",
|
||||
);
|
||||
expect(validatePinForm(form({ newPin: "123456789", confirmPin: "123456789" }))).toBe(
|
||||
"A PIN must be 4–8 digits.",
|
||||
);
|
||||
});
|
||||
|
||||
it("catches a mistyped confirmation", () => {
|
||||
expect(validatePinForm(form({ newPin: "1234", confirmPin: "1235" }))).toBe(
|
||||
"The two PINs do not match.",
|
||||
);
|
||||
});
|
||||
|
||||
it("asks for confirmation before comparing", () => {
|
||||
expect(validatePinForm(form({ newPin: "1234", confirmPin: "" }))).toBe("Confirm your new PIN.");
|
||||
});
|
||||
|
||||
it("rejects a change that changes nothing", () => {
|
||||
expect(
|
||||
validatePinForm(
|
||||
form({
|
||||
intent: "change",
|
||||
hasPin: true,
|
||||
currentPin: "1234",
|
||||
newPin: "1234",
|
||||
confirmPin: "1234",
|
||||
}),
|
||||
),
|
||||
).toBe("The new PIN is the same as the current one.");
|
||||
});
|
||||
|
||||
it("needs only the current PIN to turn one off", () => {
|
||||
expect(validatePinForm(form({ intent: "clear", hasPin: true, currentPin: "1234" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("will not turn off a PIN without the current one", () => {
|
||||
expect(validatePinForm(form({ intent: "clear", hasPin: true, currentPin: "" }))).toBe(
|
||||
"Enter your current PIN.",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports one problem at a time, most blocking first", () => {
|
||||
// Both the current PIN is missing *and* the new one is too short; the
|
||||
// current-PIN prompt is the one that comes back.
|
||||
expect(
|
||||
validatePinForm(form({ intent: "change", hasPin: true, newPin: "1", confirmPin: "2" })),
|
||||
).toBe("Enter your current PIN.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pinActionLabel", () => {
|
||||
it("names each intent", () => {
|
||||
expect(pinActionLabel("set")).toBe("Set PIN");
|
||||
expect(pinActionLabel("change")).toBe("Change PIN");
|
||||
expect(pinActionLabel("clear")).toBe("Turn off PIN");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Validation for the PIN settings form.
|
||||
*
|
||||
* Pure, and separate from the component, so the rules can be tested without
|
||||
* mounting anything — the same reason `episodeStrip.ts` exists.
|
||||
*
|
||||
* This is *not* the security boundary. Rust validates the PIN shape again in
|
||||
* `pin::validate_pin` and is the only thing that ever compares one. What lives
|
||||
* here is the difference between a form that tells you what is wrong before you
|
||||
* submit and one that bounces you off a backend error.
|
||||
*
|
||||
* TRACES: UR-083 | DR-268, DR-276
|
||||
*/
|
||||
|
||||
/** Mirrors `pin::MAX_ATTEMPTS`-adjacent shape rules in Rust. */
|
||||
export const PIN_MIN_LENGTH = 4;
|
||||
export const PIN_MAX_LENGTH = 8;
|
||||
|
||||
export type PinIntent = "set" | "change" | "clear";
|
||||
|
||||
export interface PinFormState {
|
||||
intent: PinIntent;
|
||||
/** Whether the profile currently has a PIN — decides if `currentPin` is required. */
|
||||
hasPin: boolean;
|
||||
currentPin: string;
|
||||
newPin: string;
|
||||
confirmPin: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reason this form cannot be submitted yet, or `null` when it can.
|
||||
*
|
||||
* Returns one message rather than a list: a PIN form has at most one thing
|
||||
* wrong with it worth saying, and stacking "too short" under "doesn't match"
|
||||
* reads as nagging.
|
||||
*
|
||||
* TRACES: UR-083 | DR-276
|
||||
*/
|
||||
export function validatePinForm(state: PinFormState): string | null {
|
||||
if (state.hasPin && state.currentPin.length === 0) {
|
||||
return "Enter your current PIN.";
|
||||
}
|
||||
|
||||
if (state.intent === "clear") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (state.newPin.length === 0) {
|
||||
return "Choose a PIN.";
|
||||
}
|
||||
if (!/^[0-9]+$/.test(state.newPin)) {
|
||||
return "A PIN can only contain digits.";
|
||||
}
|
||||
if (state.newPin.length < PIN_MIN_LENGTH || state.newPin.length > PIN_MAX_LENGTH) {
|
||||
return `A PIN must be ${PIN_MIN_LENGTH}–${PIN_MAX_LENGTH} digits.`;
|
||||
}
|
||||
if (state.confirmPin.length === 0) {
|
||||
return "Confirm your new PIN.";
|
||||
}
|
||||
if (state.newPin !== state.confirmPin) {
|
||||
return "The two PINs do not match.";
|
||||
}
|
||||
if (state.intent === "change" && state.currentPin === state.newPin) {
|
||||
return "The new PIN is the same as the current one.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the button should say. Derived rather than hardcoded per branch so the
|
||||
* three intents cannot drift apart in the markup.
|
||||
*
|
||||
* TRACES: UR-083 | DR-276
|
||||
*/
|
||||
export function pinActionLabel(intent: PinIntent): string {
|
||||
switch (intent) {
|
||||
case "set":
|
||||
return "Set PIN";
|
||||
case "change":
|
||||
return "Change PIN";
|
||||
case "clear":
|
||||
return "Turn off PIN";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { orderProfiles, initialsFor, tileColour, lockoutMessage } from "./profileTiles";
|
||||
import type { Profile } from "$lib/api/bindings";
|
||||
|
||||
function profile(overrides: Partial<Profile> = {}): Profile {
|
||||
return {
|
||||
userId: "u1",
|
||||
username: "Dad",
|
||||
serverId: "s1",
|
||||
avatarTag: null,
|
||||
unlockMethod: "none",
|
||||
lastUsedAt: null,
|
||||
isActive: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("orderProfiles", () => {
|
||||
it("puts the most recently used profile first", () => {
|
||||
const ordered = orderProfiles([
|
||||
profile({ userId: "a", lastUsedAt: "2026-01-01T00:00:00Z" }),
|
||||
profile({ userId: "b", lastUsedAt: "2026-03-01T00:00:00Z" }),
|
||||
]);
|
||||
expect(ordered.map((p) => p.userId)).toEqual(["b", "a"]);
|
||||
});
|
||||
|
||||
it("sorts never-used profiles after used ones, then by name", () => {
|
||||
const ordered = orderProfiles([
|
||||
profile({ userId: "z", username: "Zoe", lastUsedAt: null }),
|
||||
profile({ userId: "a", username: "Ann", lastUsedAt: null }),
|
||||
profile({ userId: "u", username: "Used", lastUsedAt: "2026-01-01T00:00:00Z" }),
|
||||
]);
|
||||
expect(ordered.map((p) => p.username)).toEqual(["Used", "Ann", "Zoe"]);
|
||||
});
|
||||
|
||||
it("does not mutate its input", () => {
|
||||
const input = [
|
||||
profile({ userId: "a", lastUsedAt: "2026-01-01T00:00:00Z" }),
|
||||
profile({ userId: "b", lastUsedAt: "2026-03-01T00:00:00Z" }),
|
||||
];
|
||||
orderProfiles(input);
|
||||
expect(input.map((p) => p.userId)).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initialsFor", () => {
|
||||
it("takes two letters from a single name", () => {
|
||||
expect(initialsFor("Dad")).toBe("DA");
|
||||
});
|
||||
|
||||
it("takes first and last initials from a multi-part name", () => {
|
||||
expect(initialsFor("Anna Marie Smith")).toBe("AS");
|
||||
});
|
||||
|
||||
it("splits on the separators usernames actually use", () => {
|
||||
expect(initialsFor("anna_smith")).toBe("AS");
|
||||
expect(initialsFor("anna.smith")).toBe("AS");
|
||||
expect(initialsFor("anna-smith")).toBe("AS");
|
||||
});
|
||||
|
||||
it("survives an empty or whitespace name", () => {
|
||||
expect(initialsFor("")).toBe("?");
|
||||
expect(initialsFor(" ")).toBe("?");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tileColour", () => {
|
||||
it("is stable for the same user", () => {
|
||||
expect(tileColour("user-123")).toBe(tileColour("user-123"));
|
||||
});
|
||||
|
||||
it("is derived from the id, not the name, so renaming keeps the colour", () => {
|
||||
// Same id, different display names — the caller only passes the id, which is
|
||||
// the point: a rename cannot move someone's tile colour.
|
||||
expect(tileColour("user-123")).toBe(tileColour("user-123"));
|
||||
expect(tileColour("user-456")).not.toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("lockoutMessage", () => {
|
||||
const now = new Date("2026-01-01T00:00:00Z");
|
||||
|
||||
it("rounds up to whole minutes", () => {
|
||||
expect(lockoutMessage("2026-01-01T00:02:30Z", now)).toBe("Try again in 3 minutes");
|
||||
});
|
||||
|
||||
it("phrases under a minute without a number", () => {
|
||||
expect(lockoutMessage("2026-01-01T00:00:30Z", now)).toBe("Try again in less than a minute");
|
||||
});
|
||||
|
||||
it("treats an elapsed lockout as over", () => {
|
||||
expect(lockoutMessage("2025-12-31T23:59:00Z", now)).toBe("Try again now");
|
||||
});
|
||||
|
||||
it("does not render NaN when the timestamp is unusable", () => {
|
||||
expect(lockoutMessage("not-a-date", now)).toBe("Try again now");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Presentation helpers for the profile picker.
|
||||
*
|
||||
* Pure, and separate from the component, because this is the half worth testing
|
||||
* and a component cannot be unit-tested without mounting it.
|
||||
*
|
||||
* Note what is *not* here: nothing decides whether a profile is locked, whether
|
||||
* a PIN is correct, or how many attempts remain. Those come from the backend as
|
||||
* an opaque `unlockMethod` and an `UnlockOutcome`. A child's profile is simply
|
||||
* one with no PIN — the app models no roles and infers no ages.
|
||||
*
|
||||
* TRACES: UR-082, UR-083 | DR-276
|
||||
*/
|
||||
|
||||
import type { Profile } from "$lib/api/bindings";
|
||||
|
||||
/**
|
||||
* Order tiles as people expect to find them: whoever used the device last is
|
||||
* leftmost, and profiles that have never been used sort after those that have.
|
||||
*
|
||||
* TRACES: UR-082 | DR-276
|
||||
*/
|
||||
export function orderProfiles(profiles: Profile[]): Profile[] {
|
||||
return [...profiles].sort((a, b) => {
|
||||
if (a.lastUsedAt && b.lastUsedAt) return b.lastUsedAt.localeCompare(a.lastUsedAt);
|
||||
if (a.lastUsedAt) return -1;
|
||||
if (b.lastUsedAt) return 1;
|
||||
return a.username.localeCompare(b.username);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initials for a tile with no avatar. At most two letters, because three fills
|
||||
* a circle badly at tile size.
|
||||
*
|
||||
* TRACES: UR-082 | DR-276
|
||||
*/
|
||||
export function initialsFor(username: string): string {
|
||||
const words = username
|
||||
.trim()
|
||||
.split(/[\s._-]+/)
|
||||
.filter(Boolean);
|
||||
if (words.length === 0) return "?";
|
||||
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
|
||||
return (words[0][0] + words[words.length - 1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable tile colour per profile, so a family learns to recognise their tile
|
||||
* by colour before they read the name. Derived from the user id rather than the
|
||||
* name, so renaming does not move someone's colour.
|
||||
*
|
||||
* TRACES: UR-082 | DR-276
|
||||
*/
|
||||
const TILE_COLOURS = ["#7b68ee", "#00a4dc", "#e8734a", "#3ba55d", "#d95f8e", "#c9a227"] as const;
|
||||
|
||||
export function tileColour(userId: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < userId.length; i++) {
|
||||
hash = (hash * 31 + userId.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return TILE_COLOURS[hash % TILE_COLOURS.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a lockout has left, phrased for a person rather than a log line.
|
||||
*
|
||||
* TRACES: UR-083 | DR-276
|
||||
*/
|
||||
export function lockoutMessage(until: string, now: Date = new Date()): string {
|
||||
const remainingMs = new Date(until).getTime() - now.getTime();
|
||||
if (!Number.isFinite(remainingMs) || remainingMs <= 0) return "Try again now";
|
||||
const minutes = Math.ceil(remainingMs / 60000);
|
||||
if (minutes <= 1) return "Try again in less than a minute";
|
||||
return `Try again in ${minutes} minutes`;
|
||||
}
|
||||
+25
-4
@@ -3,6 +3,7 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
import { auth, isAuthenticated } from "$lib/stores/auth";
|
||||
import { profiles } from "$lib/stores/profiles";
|
||||
import { home } from "$lib/stores/home";
|
||||
import { library, libraries } from "$lib/stores/library";
|
||||
import { isServerReachable } from "$lib/stores/connectivity";
|
||||
@@ -29,11 +30,31 @@
|
||||
let previousServerReachable = false;
|
||||
let isAndroid = $state(false);
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
// Where an unauthenticated app goes depends on what this device holds. A
|
||||
// single account with no PIN goes straight to login exactly as before; a
|
||||
// device with several profiles, or one whose last profile is PIN-protected,
|
||||
// goes to the picker instead. The decision is the backend's — the frontend
|
||||
// asks rather than counting profiles itself, because it also turns on a stored
|
||||
// setting and on which profiles have a PIN. (DR-274)
|
||||
let routingAway = false;
|
||||
$effect(() => {
|
||||
if (!$isAuthenticated) {
|
||||
goto("/login");
|
||||
}
|
||||
if ($isAuthenticated || routingAway) return;
|
||||
routingAway = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const target = await profiles.startupTarget();
|
||||
const found = await profiles.refresh();
|
||||
// The picker is only a picker when there is something to pick. With no
|
||||
// profiles stored it would render an empty room, so first run still
|
||||
// goes to login.
|
||||
await goto(target.type === "picker" && found.length > 0 ? "/profiles" : "/login");
|
||||
} catch (error) {
|
||||
log.error("Could not resolve startup target:", error);
|
||||
await goto("/login");
|
||||
} finally {
|
||||
routingAway = false;
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// Load home sections when authenticated
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
<!--
|
||||
"Who's watching" — the profile picker.
|
||||
|
||||
Everything here is presentation. Which profiles exist, whether one is locked,
|
||||
whether a code was right and how many guesses are left all arrive from Rust;
|
||||
this page renders them. In particular there is no notion of an adult or a child
|
||||
account anywhere in this file — a child's profile is simply one whose
|
||||
`unlockMethod` is "none", which is one tap.
|
||||
|
||||
TRACES: UR-082, UR-083, UR-084 | DR-276
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import { profiles } from "$lib/stores/profiles";
|
||||
import { isAuthenticated } from "$lib/stores/auth";
|
||||
import { navigateBack } from "$lib/utils/navigation";
|
||||
import type { Profile, UnlockOutcome } from "$lib/api/bindings";
|
||||
import PinPad from "$lib/components/PinPad.svelte";
|
||||
import { orderProfiles, initialsFor, tileColour, lockoutMessage } from "$lib/utils/profileTiles";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("ProfilePicker");
|
||||
|
||||
type Mode = "picker" | "pin" | "password" | "add" | "manage";
|
||||
|
||||
let mode = $state<Mode>("picker");
|
||||
let selected = $state<Profile | null>(null);
|
||||
let pin = $state("");
|
||||
let password = $state("");
|
||||
let entryError = $state<string | null>(null);
|
||||
let busy = $state(false);
|
||||
|
||||
// Add-profile form
|
||||
let newUsername = $state("");
|
||||
let newPassword = $state("");
|
||||
let newPin = $state("");
|
||||
let usePinForNew = $state(false);
|
||||
|
||||
const ordered = $derived(orderProfiles($profiles.profiles));
|
||||
|
||||
onMount(() => {
|
||||
// Settings deep-links here for add/remove rather than duplicating the tile
|
||||
// grid: the tiles are the natural place to act on a profile, and two copies
|
||||
// of that list would drift.
|
||||
if ($page.url.searchParams.get("manage") === "1") mode = "manage";
|
||||
void profiles.refresh();
|
||||
});
|
||||
|
||||
function reset() {
|
||||
mode = "picker";
|
||||
selected = null;
|
||||
pin = "";
|
||||
password = "";
|
||||
entryError = null;
|
||||
newUsername = "";
|
||||
newPassword = "";
|
||||
newPin = "";
|
||||
usePinForNew = false;
|
||||
}
|
||||
|
||||
function renderOutcome(outcome: UnlockOutcome): boolean {
|
||||
switch (outcome.type) {
|
||||
case "ok":
|
||||
return true;
|
||||
case "wrongPin":
|
||||
pin = "";
|
||||
entryError =
|
||||
outcome.attemptsRemaining === 1
|
||||
? "Wrong PIN. One more try before this profile locks."
|
||||
: `Wrong PIN. ${outcome.attemptsRemaining} tries left.`;
|
||||
return false;
|
||||
case "lockedOut":
|
||||
pin = "";
|
||||
entryError = `Too many wrong PINs. ${lockoutMessage(outcome.until)}`;
|
||||
return false;
|
||||
case "needsPassword":
|
||||
mode = "password";
|
||||
entryError = "Sign in with your password to continue.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function choose(profile: Profile) {
|
||||
if (profile.unlockMethod === "pin") {
|
||||
selected = profile;
|
||||
pin = "";
|
||||
entryError = null;
|
||||
mode = "pin";
|
||||
return;
|
||||
}
|
||||
await enter(profile, null);
|
||||
}
|
||||
|
||||
async function enter(profile: Profile, code: string | null) {
|
||||
busy = true;
|
||||
entryError = null;
|
||||
try {
|
||||
const outcome = await profiles.unlock(profile.userId, code);
|
||||
if (renderOutcome(outcome)) {
|
||||
reset();
|
||||
await goto("/");
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Unlock failed:", error);
|
||||
entryError = error instanceof Error ? error.message : "Could not switch profile";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function enterWithPassword(event: Event) {
|
||||
event.preventDefault();
|
||||
if (!selected) return;
|
||||
busy = true;
|
||||
entryError = null;
|
||||
try {
|
||||
const outcome = await profiles.unlockWithPassword(selected.userId, password);
|
||||
if (renderOutcome(outcome)) {
|
||||
reset();
|
||||
await goto("/");
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Password unlock failed:", error);
|
||||
entryError = error instanceof Error ? error.message : "Sign-in failed";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addProfile(event: Event) {
|
||||
event.preventDefault();
|
||||
busy = true;
|
||||
entryError = null;
|
||||
try {
|
||||
await profiles.add(newUsername, newPassword, usePinForNew ? newPin : null);
|
||||
reset();
|
||||
} catch (error) {
|
||||
log.error("Add profile failed:", error);
|
||||
entryError = error instanceof Error ? error.message : "Could not add profile";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeProfile(profile: Profile) {
|
||||
busy = true;
|
||||
entryError = null;
|
||||
try {
|
||||
await profiles.remove(profile.userId);
|
||||
} catch (error) {
|
||||
entryError = error instanceof Error ? error.message : "Could not remove profile";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-full flex items-center justify-center p-6">
|
||||
<div class="w-full max-w-3xl">
|
||||
<!--
|
||||
Reached from the account menu, the picker must not be a one-way door: a
|
||||
signed-in viewer who opens it and changes their mind (or fails a PIN on
|
||||
someone else's tile) needs a way back to the session they still have. At
|
||||
startup there is no session behind it, so there is nothing to go back to
|
||||
and this stays hidden. (DR-276)
|
||||
-->
|
||||
{#if $isAuthenticated && mode !== "pin" && mode !== "password"}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => navigateBack("/")}
|
||||
class="mb-6 text-sm text-gray-400 hover:text-white flex items-center gap-1"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back
|
||||
</button>
|
||||
{/if}
|
||||
{#if mode === "picker" || mode === "manage"}
|
||||
<h1 class="text-3xl font-semibold text-center mb-2">
|
||||
{mode === "manage" ? "Manage profiles" : "Who's watching?"}
|
||||
</h1>
|
||||
<p class="text-gray-400 text-center mb-10 text-sm">
|
||||
{#if mode === "manage"}
|
||||
Removing a profile only affects this device. It does not sign that person out elsewhere.
|
||||
{:else}
|
||||
Everyone here signs in to the same server.
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if $profiles.error}
|
||||
<div class="mb-6 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
|
||||
{$profiles.error}
|
||||
</div>
|
||||
{/if}
|
||||
{#if entryError}
|
||||
<div class="mb-6 p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
|
||||
{entryError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-8">
|
||||
{#each ordered as profile (profile.userId)}
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (mode === "manage" ? undefined : choose(profile))}
|
||||
disabled={busy || mode === "manage"}
|
||||
class="relative w-28 h-28 rounded-2xl flex items-center justify-center text-3xl font-semibold text-white/90 transition-transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-white disabled:hover:scale-100"
|
||||
style="background-color: {tileColour(profile.userId)}"
|
||||
aria-label="Switch to {profile.username}"
|
||||
>
|
||||
{initialsFor(profile.username)}
|
||||
{#if profile.unlockMethod === "pin"}
|
||||
<span
|
||||
class="absolute bottom-2 right-2 bg-black/50 rounded-full p-1.5"
|
||||
aria-label="PIN required"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<span class="text-sm text-gray-300">{profile.username}</span>
|
||||
{#if mode === "manage" && !profile.isActive}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeProfile(profile)}
|
||||
disabled={busy}
|
||||
class="text-xs text-red-400 hover:text-red-300"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
{:else if mode === "manage"}
|
||||
<span class="text-xs text-gray-500">In use</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if mode === "picker"}
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
entryError = null;
|
||||
mode = "add";
|
||||
}}
|
||||
class="w-28 h-28 rounded-2xl border-2 border-dashed border-gray-600 hover:border-gray-400 flex items-center justify-center text-gray-500 hover:text-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-white"
|
||||
aria-label="Add a profile"
|
||||
>
|
||||
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-sm text-gray-500">Add profile</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-12">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
entryError = null;
|
||||
mode = mode === "manage" ? "picker" : "manage";
|
||||
}}
|
||||
class="text-sm text-gray-400 hover:text-white"
|
||||
>
|
||||
{mode === "manage" ? "Done" : "Manage profiles"}
|
||||
</button>
|
||||
</div>
|
||||
{:else if mode === "pin" && selected}
|
||||
<div class="flex flex-col items-center gap-8">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div
|
||||
class="w-20 h-20 rounded-2xl flex items-center justify-center text-2xl font-semibold text-white/90"
|
||||
style="background-color: {tileColour(selected.userId)}"
|
||||
>
|
||||
{initialsFor(selected.username)}
|
||||
</div>
|
||||
<h1 class="text-xl font-medium">{selected.username}</h1>
|
||||
</div>
|
||||
|
||||
<PinPad
|
||||
bind:value={pin}
|
||||
error={entryError}
|
||||
disabled={busy}
|
||||
onsubmit={(code) => selected && enter(selected, code)}
|
||||
oncancel={reset}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
entryError = null;
|
||||
password = "";
|
||||
mode = "password";
|
||||
}}
|
||||
class="text-sm text-gray-400 hover:text-white underline"
|
||||
>
|
||||
Forgot your PIN? Use your password
|
||||
</button>
|
||||
</div>
|
||||
{:else if mode === "password" && selected}
|
||||
<form onsubmit={enterWithPassword} class="max-w-sm mx-auto space-y-4">
|
||||
<h1 class="text-2xl font-semibold text-center mb-6">
|
||||
Sign in as {selected.username}
|
||||
</h1>
|
||||
|
||||
<div>
|
||||
<label for="profile-password" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
id="profile-password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
autofocus
|
||||
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if entryError}
|
||||
<div class="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
|
||||
{entryError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={reset}
|
||||
class="flex-1 py-3 rounded-lg border border-gray-700 hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !password}
|
||||
class="flex-1 py-3 rounded-lg bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 font-medium"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else if mode === "add"}
|
||||
<form onsubmit={addProfile} class="max-w-sm mx-auto space-y-4">
|
||||
<h1 class="text-2xl font-semibold text-center mb-2">Add a profile</h1>
|
||||
<p class="text-gray-400 text-sm text-center mb-6">
|
||||
Another account on the server you are already connected to.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label for="new-username" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="new-username"
|
||||
type="text"
|
||||
bind:value={newUsername}
|
||||
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="new-password" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="flex items-start gap-3 text-sm text-gray-300">
|
||||
<input type="checkbox" bind:checked={usePinForNew} class="mt-1" disabled={busy} />
|
||||
<span>
|
||||
Protect this profile with a PIN
|
||||
<span class="block text-gray-500 text-xs mt-1">
|
||||
Leave this off for a child's profile so it opens with one tap. A PIN controls who can
|
||||
switch to an account — what each account may watch is set on the Jellyfin server.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{#if usePinForNew}
|
||||
<div>
|
||||
<label for="new-pin" class="block text-sm font-medium text-gray-300 mb-2">
|
||||
PIN (4–8 digits)
|
||||
</label>
|
||||
<input
|
||||
id="new-pin"
|
||||
type="password"
|
||||
inputmode="numeric"
|
||||
bind:value={newPin}
|
||||
class="w-full px-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white tracking-widest"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if entryError}
|
||||
<div class="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
|
||||
{entryError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={reset}
|
||||
class="flex-1 py-3 rounded-lg border border-gray-700 hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !newUsername || (usePinForNew && newPin.length < 4)}
|
||||
class="flex-1 py-3 rounded-lg bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 font-medium"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,6 +2,8 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { profiles } from "$lib/stores/profiles";
|
||||
import ProfileSecuritySettings from "$lib/components/settings/ProfileSecuritySettings.svelte";
|
||||
import type {
|
||||
AudioSettings,
|
||||
CacheConfig,
|
||||
@@ -147,8 +149,13 @@
|
||||
// Promise and Svelte would never invoke it as a teardown.
|
||||
onDestroy(unsubscribeNativeVideo);
|
||||
|
||||
// Mirrors the stored setting; the picker itself always appears for a
|
||||
// PIN-protected profile regardless of this. (DR-274)
|
||||
let askOnStart = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
await loadSettings();
|
||||
askOnStart = await commands.profilesGetAskOnStart();
|
||||
supportsNativeVideo = (await getPlaybackCapabilities()).supportsNativeVideo;
|
||||
|
||||
// Which update story this platform gets. Android cannot install its own
|
||||
@@ -985,6 +992,46 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Profiles. Deliberately minimal here: adding, removing and PIN changes
|
||||
all live on the picker itself, where the tiles are. What belongs in
|
||||
settings is the one device-wide preference and a way in.
|
||||
TRACES: UR-082, UR-083 | DR-274, DR-276 -->
|
||||
<div class="border-t border-gray-700 pt-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Profiles</h2>
|
||||
|
||||
<div class="bg-[var(--color-surface)] rounded-lg p-6 space-y-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Ask who's watching</h3>
|
||||
<p class="text-sm text-gray-400">
|
||||
Show the profile picker when the app starts. A profile with a PIN always asks,
|
||||
whatever this is set to.
|
||||
</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={askOnStart}
|
||||
onchange={() => profiles.setAskOnStart(askOnStart)}
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
class="w-11 h-6 bg-gray-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[var(--color-jellyfin)]"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-700 pt-5">
|
||||
<ProfileSecuritySettings />
|
||||
<p class="text-xs text-gray-500 mt-4">
|
||||
A PIN controls who can switch to an account on this device. What each account is
|
||||
allowed to watch is set on the Jellyfin server, not here. Switching profile lives in
|
||||
the account menu.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Settings -->
|
||||
<div class="border-t border-gray-700 pt-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">Search</h2>
|
||||
|
||||
Reference in New Issue
Block a user