Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10d77f1380 | ||
|
|
03c0b5cd17 | ||
|
|
27a995f877 | ||
|
|
f902caa07f | ||
|
|
fb3d0014ef | ||
|
|
da762da55d | ||
|
|
8a04a6fad0 | ||
|
|
c0399e4ebd | ||
|
|
66e7889030 | ||
|
|
d25f6be697 | ||
|
|
079153d9d5 | ||
|
|
1ba836928f | ||
|
|
bbccc8567c | ||
|
|
ad05dcd484 | ||
|
|
8a2b484e36 | ||
|
|
1d6487774c |
@@ -0,0 +1,266 @@
|
||||
name: '📱 Test APK'
|
||||
|
||||
# An installable APK from any branch, on demand, without cutting a release.
|
||||
#
|
||||
# Why this exists separately from build-release.yml: that workflow is tag-driven,
|
||||
# builds Linux + Windows + Android and then *creates a release*, which is not
|
||||
# what you want from a feature branch. This builds one Android APK from whatever
|
||||
# ref you dispatch it on and hands it back as an artifact.
|
||||
#
|
||||
# Deliberately `workflow_dispatch` only — no push trigger. The runner has a
|
||||
# single slot shared with two other projects, so a build on every feature-branch
|
||||
# commit would starve everything else. Dispatch it when you actually want to
|
||||
# install something.
|
||||
#
|
||||
# Both variants install as com.dtourolle.jellytau.debug ("JellyTau Debug"),
|
||||
# side by side with a real install and with their own data directory. Neither
|
||||
# needs the release signing key.
|
||||
#
|
||||
# Getting the APK to somebody else: Gitea artifacts need an account with read
|
||||
# access to download, so `publish: true` also attaches the APK 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:
|
||||
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. Use this unless
|
||||
# you need stack traces: R8 stripping JNI-loaded classes has broken
|
||||
# release APKs here before, and a plain debug build cannot catch it.
|
||||
- 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, for testers with no Gitea account'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
# One test build at a time; a newer dispatch supersedes an in-flight one.
|
||||
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 (${{ inputs.variant }}, ${{ inputs.abi }})
|
||||
runs-on: linux/amd64
|
||||
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
|
||||
|
||||
- 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 [ "${{ inputs.variant }}" = "side-by-side-release" ]; then
|
||||
./scripts/build-android.sh release --debug --abi "${{ inputs.abi }}"
|
||||
else
|
||||
./scripts/build-android.sh debug --abi "${{ inputs.abi }}"
|
||||
fi
|
||||
|
||||
- name: Collect APK
|
||||
id: collect
|
||||
run: |
|
||||
mkdir -p dist/test-apk
|
||||
if [ "${{ inputs.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 ${{ inputs.variant }}"
|
||||
find src-tauri/gen/android/app/build/outputs/apk -name '*.apk' || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REF_NAME=$(echo "${GITHUB_REF#refs/heads/}" | tr '/' '-')
|
||||
OUT="dist/test-apk/jellytau-${REF_NAME}-${GITHUB_SHA::8}-${{ inputs.variant }}.apk"
|
||||
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 "### 📱 Test APK"
|
||||
echo ""
|
||||
echo "| | |"
|
||||
echo "|---|---|"
|
||||
echo "| Branch | \`${GITHUB_REF#refs/heads/}\` |"
|
||||
echo "| Commit | \`${GITHUB_SHA::8}\` |"
|
||||
echo "| Variant | \`${{ inputs.variant }}\` |"
|
||||
echo "| ABI | \`${{ inputs.abi }}\` |"
|
||||
echo "| Size | $(du -h "$OUT" | cut -f1) |"
|
||||
echo "| SHA256 | \`$(sha256sum "$OUT" | cut -d' ' -f1)\` |"
|
||||
echo ""
|
||||
echo "Installs as \`com.dtourolle.jellytau.debug\` — side by side with a real"
|
||||
echo "install, with its own data directory. Download the artifact, then:"
|
||||
echo ""
|
||||
echo '```'
|
||||
echo "adb install -r $(basename "$OUT")"
|
||||
echo '```'
|
||||
} >> "$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 off a feature branch. The tag here is derived from the
|
||||
# branch name and carries no version, so nothing else reacts to it.
|
||||
#
|
||||
# This also cannot reach existing users. 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 without the link.
|
||||
- name: Publish as a pre-release
|
||||
if: ${{ inputs.publish }}
|
||||
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}"
|
||||
BRANCH="${GITHUB_REF#refs/heads/}"
|
||||
TAG="test-$(echo "$BRANCH" | tr '/' '-')"
|
||||
|
||||
# printf, not a heredoc: inside a YAML block scalar every line is
|
||||
# indented, and a heredoc terminator has to sit at column 0.
|
||||
BODY=$(printf '%s\n' \
|
||||
"Test build of \`$BRANCH\` at \`${GITHUB_SHA::8}\` — **not a release**." \
|
||||
"" \
|
||||
"Installs as **JellyTau Debug** (\`com.dtourolle.jellytau.debug\`), alongside a" \
|
||||
"normal install and with its own separate data. Uninstalling it does not touch" \
|
||||
"the real app." \
|
||||
"" \
|
||||
"Variant: \`${{ inputs.variant }}\` · ABI: \`${{ inputs.abi }}\`" \
|
||||
"" \
|
||||
"Android will warn about installing from an unknown source; that is expected" \
|
||||
"for a build signed with a debug key rather than the store key.")
|
||||
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg tag "$TAG" \
|
||||
--arg name "Test build: $BRANCH" \
|
||||
--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
|
||||
# Re-dispatching for the same branch replaces the previous APK rather
|
||||
# than accumulating one release per attempt.
|
||||
echo "ℹ️ Pre-release $TAG exists; reusing it"
|
||||
RELEASE_ID=$(curl -fsS "$API/repos/$REPO/releases/tags/$TAG" \
|
||||
-H "Authorization: token $TOKEN" | jq -r '.id')
|
||||
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
|
||||
|
||||
for f in dist/test-apk/*.apk; do
|
||||
echo "⬆️ $(basename "$f")"
|
||||
curl -fsS -X POST \
|
||||
"$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$(basename "$f")" \
|
||||
-H "Authorization: token $TOKEN" -F "attachment=@$f" >/dev/null
|
||||
done
|
||||
|
||||
{
|
||||
echo ""
|
||||
echo "**Published:** ${GITHUB_SERVER_URL}/${REPO}/releases/tag/${TAG}"
|
||||
echo ""
|
||||
echo "Public link — no Gitea account needed. Delete the release when testing is done."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: jellytau-test-apk
|
||||
path: dist/test-apk/
|
||||
retention-days: 7
|
||||
@@ -9,6 +9,97 @@ 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.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
|
||||
|
||||
- **A newly-added album shows up as one album, not as fourteen songs.**
|
||||
Importing an album filled the whole Recently Added row with that one album,
|
||||
one card per track, burying everything else added that week. The app does ask
|
||||
the server to group new tracks under their album, but the server only manages
|
||||
it for a track whose folder structure actually resolves an album — and older
|
||||
servers ignore the request altogether. The row is now grouped here as well, so
|
||||
what it shows is a property of the app rather than of the server it is talking
|
||||
to: one card per album, opening the album, keeping its artwork and artists, and
|
||||
standing where the first of its tracks stood so the newest is still first.
|
||||
Standalone tracks and movies are unaffected. (UR-024 → JA-016)
|
||||
|
||||
- **A binge plays on past the end of a season.** Autoplay only ever looked
|
||||
inside the current season, so the last episode of one was the end of the line:
|
||||
with the screen locked and background audio playing, that is felt as playback
|
||||
simply stopping mid-binge, with no visible player to start it again. It now
|
||||
crosses into the first episode of the next season that has any, skipping empty
|
||||
seasons and never wandering into Specials. A sleep timer set to end-of-episode
|
||||
still stops at the boundary — crossing it is autoplay's decision, not the
|
||||
timer's. (UR-023, UR-040 → DR-263)
|
||||
|
||||
- **The episode you just finished is no longer offered as the one to play next.**
|
||||
Watch an episode to the end, press Back, and the season list still ringed that
|
||||
same episode as "Up next" and scrolled to it. Nothing recorded the completion
|
||||
on the device — progress writes saved a position but never marked an episode
|
||||
watched, and cached server data dropped the watched flag on the way in — while
|
||||
the server's own "next up" answer is briefly one report behind and named the
|
||||
episode that had just ended. An episode is now counted as finished either when
|
||||
the server says so or when its position is past the same 95% mark that already
|
||||
disqualifies it from "continue watching", and the server's answer is checked
|
||||
against that before it is accepted. The highlight, the badge, the auto-scroll
|
||||
and which season opens expanded all follow the episode that really is next —
|
||||
and the checkmarks in the season list stay put. (UR-025, UR-062 → DR-264)
|
||||
|
||||
### 🛠 Development
|
||||
|
||||
- **A debug build installs alongside a release build again.** `bun run
|
||||
android:dev` produced an APK carrying the release application id, so installing
|
||||
it over a real release build failed outright and the obvious way out was to
|
||||
uninstall the release app and lose its data. The Tauri CLI rewrites the debug
|
||||
section of the generated Gradle file to inject its own settings, and that
|
||||
rewrite dropped the suffix that keeps the two apps apart; the suffix has moved
|
||||
somewhere the rewrite does not reach, and the build now asserts the id the APK
|
||||
actually carries rather than assuming it.
|
||||
|
||||
## v0.11.3
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- **The A-Z jump strip no longer runs under the mini player.** On a long
|
||||
alphabetical list — an album's tracks, the music library — the last few
|
||||
letters sat behind the mini player and bottom nav, where they could not be
|
||||
tapped. The strip was sizing itself against the window minus a hardcoded guess
|
||||
at how tall those bars are, which stopped being true when they became part of
|
||||
the normal layout instead of floating over it, and was always short by the
|
||||
height of your phone's navigation bar. It now measures the list it belongs to
|
||||
and stops exactly where that list stops, whatever is below. (UR-007 → DR-262)
|
||||
|
||||
- **Subtitles sit on the picture instead of on a black bar.** Every line arrived
|
||||
in an opaque box, which is what Android hands back when no captioning
|
||||
preferences have been set. The box is gone; the text keeps its own outline so
|
||||
it stays readable over a bright scene. If you *have* set up captions in
|
||||
Android's accessibility settings, your colours, typeface and edges are still
|
||||
used — only the background is cleared. (UR-020 → DR-261)
|
||||
|
||||
## v0.11.2
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
@@ -85,6 +92,8 @@ silently correct an out-of-range index — which is exactly why it was reported
|
||||
| Background-audio base applied in two display-only places (DR-159) | v0.2.9 | **v0.5.3** | pickaxe |
|
||||
| Positions reported as 0 before the first tick, and always 0 for webview media (DR-178/179/180) | v0.5.3 | **v0.5.5** | feature (DR-159's tick boundary) |
|
||||
| Length-less handoff transcode left to the player's own load-error retry, which can only restart it (DR-203) | v0.0.16 | **v0.8.2** | feature (the handoff's progressive-mp3 choice) |
|
||||
| Recently Added trusted the server to group new tracks — `GroupItems=true` only groups a track whose parent chain resolves a `MusicAlbum`, and older servers ignore it | v0.5.1 | **v0.11.4** | feature (the v0.5.1 fix for the same symptom) |
|
||||
| PiP and the background-audio handoff both armable, decided by one `isInPictureInPictureMode` sample (DR-266) | v0.5.3 | **v0.11.5** | feature (PiP on the HTML5 path, beside a toggle that had shipped in v0.0.16) |
|
||||
|
||||
Three of these are worth separating out, because the defect is not a mistake in
|
||||
the code so much as **plumbing that was built and never connected**:
|
||||
|
||||
+40
-5
@@ -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
|
||||
@@ -454,6 +458,22 @@ Internal architecture, components, and application logic.
|
||||
| DR-258 | An audio-track change is honoured by **re-opening the stream** when the stream cannot carry the track. Jellyfin builds a transcode around one `AudioStreamIndex`, so the alternate tracks are not in it — but the native path only ever called `setAudioTrack(n)`, which indexes ExoPlayer's audio track *groups*. On Android that is the common case, since any source whose default audio codec the device cannot decode is transcoded: ExoPlayer held one audio track while the menu listed every track in the file, so every selection warned `Invalid audio track index` and was dropped, leaving the default track playing with nothing in the UI saying so. `determine_audio_track_switch_strategy` now decides by whether the stream in front of the engine carries the track at all — a direct play still selects in place, a transcode is re-negotiated at the chosen index and resumed. Where it resumes is the player's answer, not the UI's: the native path has no `<video>` element to read, so it sends no position, and defaulting that to zero re-opened the film at the beginning | Player | UR-021, UR-005 | Done |
|
||||
| DR-259 | Subtitle URLs address Jellyfin's route, `/Videos/{item}/{source}/Subtitles/{index}/Stream.{format}`. The `Stream.` segment was missing, which matches no route and 404s, so every sideloaded subtitle failed to fetch. Since media3 1.5 a sideloaded text track only becomes a track group once its file is parsed, so 42 failed fetches left ExoPlayer with no text tracks at all and subtitle selection warned `available: 0` and did nothing. The URL tests that existed asserted the shape of a *mock helper* duplicating the format string rather than the URL the app requests, which is why a route error survived from the first release | Repository | UR-020 | Done |
|
||||
| DR-260 | Subtitle cues are **drawn**. ExoPlayer decodes subtitles and delivers them to a listener; it draws none of them itself, and native video here is a bare `TextureView` the WebView composites over rather than a `PlayerView`, so nothing was holding the cues and a selected track rendered nowhere. The gap was invisible while every subtitle URL 404ed (DR-259) — with no text track to select there was never a cue to drop, so fixing the URL is what exposed it. `media3-ui`'s `SubtitleView` now takes each `CueGroup` from `onCues` and is attached at index 1 of the content view: above the video, still below the WebView, so cues sit over the picture and under the app's own controls. It is fitted to the letterboxed video rect rather than the screen, so cues stay inside the picture and follow it on rotation, and is torn down with the surface it belongs to. Verified on a device | Player | UR-020, UR-003 | Done |
|
||||
| DR-261 | Subtitles are drawn **over the picture, not on a black bar across it**. `SubtitleView.setUserDefaultStyle()` reads Android's captioning preferences and falls back to media3's `DEFAULT` when the viewer has set none — and that default is white on opaque black, so every line arrived in a box as wide as the text. The viewer's own style is kept and only the two colours that paint a box, background and window, are cleared: someone who has configured captions in accessibility settings has said something specific about colour, typeface and edges, and replacing all of it to remove a background would answer a question they did not ask. A style specifying no edge gets a black outline, because without a box the text must supply its own contrast or it is unreadable over a bright scene; a style that already names an edge keeps it | Player | UR-020 | Done (pending device verification) |
|
||||
| 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-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 |
|
||||
|
||||
---
|
||||
@@ -467,10 +487,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 |
|
||||
| UR-007 | IR-010 | DR-007, DR-008, DR-016, DR-257, DR-262 |
|
||||
| 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 |
|
||||
@@ -486,7 +506,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-020 | IR-016, IR-018 | DR-023, DR-176 | <!-- IR-018 delivered by ExoPlayer + HTML5 `<track>`, not libmpv -->
|
||||
| UR-021 | IR-016, IR-019 | DR-024 | <!-- IR-019 delivered by ExoPlayer + HLS stream re-open, not libmpv -->
|
||||
| UR-022 | IR-017 | DR-025 |
|
||||
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
|
||||
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049, DR-263 |
|
||||
| UR-024 | IR-010 | DR-027 |
|
||||
| UR-025 | IR-015 | DR-028, DR-131, DR-132, DR-178, DR-179 |
|
||||
| UR-026 | - | DR-029, DR-048, DR-050 |
|
||||
@@ -503,8 +523,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 |
|
||||
| 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 |
|
||||
@@ -543,6 +563,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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -780,7 +803,19 @@ Internal architecture, components, and application logic.
|
||||
| UT-232 | A transcode's audio-track change re-opens the stream, a direct play selects in place, and an HTML5 element reloads either way — the engine is only asked to select a track the stream actually carries | DR-258 | Done |
|
||||
| UT-233 | The position a re-opened stream resumes at comes from the engine when the caller has none, and a non-finite or negative position is treated as absent rather than passed to a backend that rejects it | DR-258 | Done |
|
||||
| UT-234 | A subtitle URL targets Jellyfin's `Stream.{format}` route, asserted against the repository that builds it rather than a mock that restates it | DR-259 | Done |
|
||||
| UT-235 | The A-Z strip's last letter stays above the bottom nav, above the mini player while audio plays, and above the taller remote-mode mini player | DR-262 | Done |
|
||||
| UT-236 | The strip still fills the space it does have rather than stopping a letter short | DR-262 | Done |
|
||||
| UT-237 | The strip falls back to the viewport with no scroll container, and never computes a negative height when scrolled past its floor | DR-262 | Done |
|
||||
| UT-238 | The last episode of a season rolls over into the first of the next, skips an empty season on the way, and still stops there when the sleep timer says so | DR-263 | Done |
|
||||
| UT-239 | An episode watched to the end is not the current episode: not when the server's Next Up still names it (the stale answer is skipped in favour of the one after it), and not offline, where it counts as watched in the furthest-watched scan | DR-264 | Done |
|
||||
| UT-240 | Caching a server result mirrors its played flag locally — as synced, never invented for an item that carries no user data, and never over an unsynced local toggle | DR-264 | Done |
|
||||
| UT-241 | A newly-imported album reads as one album card, not one card per song: three tracks sharing an album id collapse into a single `MusicAlbum` entry that opens the album, keeps its artwork and album artist, drops track-only detail (track number, duration, album link), and takes the position of the first of its tracks so recency order and the neighbouring movie survive | JA-016 | Done |
|
||||
| UT-242 | When the server did group, its own album row wins — its overview and detail survive and the tracks it also returned add no second card for the same album | JA-016 | Done |
|
||||
| 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 |
|
||||
### 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.
|
||||
+7769
-6916
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.11.2",
|
||||
"version": "0.11.5",
|
||||
"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.10.1
|
||||
pkgver=0.11.5
|
||||
pkgrel=1
|
||||
pkgdesc="A cross-platform Jellyfin client"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -128,6 +128,44 @@ else
|
||||
bun run tauri android build --apk --debug "${TARGET_ARGS[@]}"
|
||||
fi
|
||||
|
||||
# The applicationId the APK actually carries — not the one build.gradle.kts asks
|
||||
# for. `tauri android build` rewrites the debug `buildTypes` block in the
|
||||
# generated gradle file to inject its keepDebugSymbols entries, and that rewrite
|
||||
# used to drop `applicationIdSuffix` with it, silently producing a debug APK
|
||||
# under the release applicationId. Installing that over a real release build
|
||||
# fails with INSTALL_FAILED_UPDATE_INCOMPATIBLE, whose only obvious remedy is
|
||||
# uninstalling the release app and losing its data — so this fails the build
|
||||
# instead. The suffix now lives outside the rewritten block (see
|
||||
# src-tauri/android/app/build.gradle.kts); this checks that it survived.
|
||||
assert_application_id() {
|
||||
local variant="$1" expected="$2"
|
||||
local metadata="src-tauri/gen/android/app/build/outputs/apk/universal/$variant/output-metadata.json"
|
||||
|
||||
[ -f "$metadata" ] || return 0
|
||||
|
||||
local actual
|
||||
actual=$(sed -n 's/.*"applicationId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$metadata" | head -1)
|
||||
|
||||
if [ -n "$actual" ] && [ "$actual" != "$expected" ]; then
|
||||
echo ""
|
||||
echo "❌ APK applicationId is '$actual', expected '$expected'."
|
||||
echo " A build meant for the side-by-side slot came out under the"
|
||||
echo " release applicationId; installing it would collide with a real"
|
||||
echo " install. Check that the applicationIdSuffix at the bottom of"
|
||||
echo " src-tauri/android/app/build.gradle.kts survived into"
|
||||
echo " src-tauri/gen/android/app/build.gradle.kts."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$BUILD_TYPE" = "debug" ]; then
|
||||
assert_application_id debug "com.dtourolle.jellytau.debug"
|
||||
elif [ "$SIDE_BY_SIDE" = "1" ]; then
|
||||
assert_application_id release "com.dtourolle.jellytau.debug"
|
||||
else
|
||||
assert_application_id release "com.dtourolle.jellytau"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ APK build complete!"
|
||||
echo "📱 APK location: src-tauri/gen/android/app/build/outputs/apk/"
|
||||
|
||||
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.2"
|
||||
version = "0.11.5"
|
||||
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.2"
|
||||
version = "0.11.5"
|
||||
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,43 @@ 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` builds one from **any branch, on demand**
|
||||
— run it from Gitea's Actions tab (`workflow_dispatch`) against the ref you want.
|
||||
It is not a release: nothing is tagged, published, or signed with the real key.
|
||||
|
||||
Two variants, both installing into the `com.dtourolle.jellytau.debug` slot:
|
||||
|
||||
| Variant | What it is | When |
|
||||
|---------|-----------|------|
|
||||
| `side-by-side-release` (default) | 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 |
|
||||
|
||||
There is deliberately **no push trigger**: the runner has one slot shared with
|
||||
two other projects, so building on every feature-branch commit would starve
|
||||
them. The APK lands as the `jellytau-test-apk` artifact (7-day retention), named
|
||||
for the branch and short SHA, with its size and SHA256 in the run summary.
|
||||
|
||||
#### Sending a build to an outside tester
|
||||
|
||||
Gitea **artifacts require an account** with read access to download, so an
|
||||
artifact is no use to someone outside the project. Tick **`publish`** on the
|
||||
dispatch and the APK is also attached to a **pre-release**, whose assets are a
|
||||
plain public URL on a public repo — no account, no MR, no merge to `master`.
|
||||
|
||||
Two things make that safe to do from a feature branch:
|
||||
|
||||
- The tag is `test-<branch>`, **not** `v*`. Only `v*` triggers
|
||||
`build-release.yml`, so nothing else reacts to it.
|
||||
- It cannot reach existing users. The desktop updater reads a static
|
||||
`latest.json` from the `updater` branch, not the release list, so a
|
||||
pre-release published this way is invisible to anyone without the link.
|
||||
|
||||
Re-dispatching for the same branch replaces the APK on the existing
|
||||
pre-release rather than piling up one release per attempt. Delete the release
|
||||
when testing is over.
|
||||
|
||||
### Key Files
|
||||
|
||||
Player-related Kotlin files:
|
||||
|
||||
@@ -73,7 +73,9 @@ android {
|
||||
// fully-qualified class names Rust looks up over JNI, the manifest
|
||||
// <service> entry and the R8 keep rules are all unaffected. The
|
||||
// FileProvider authority is already ${applicationId}-relative.
|
||||
applicationIdSuffix = ".debug"
|
||||
//
|
||||
// The suffix itself is applied AFTER this block -- see the bottom of
|
||||
// this file. It cannot live here.
|
||||
versionNameSuffix = "-debug"
|
||||
manifestPlaceholders["appLabel"] = "JellyTau Debug"
|
||||
manifestPlaceholders["activityLabel"] = "JellyTau Debug"
|
||||
@@ -127,6 +129,30 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// The debug applicationId suffix, applied from OUTSIDE the `buildTypes` block.
|
||||
//
|
||||
// `tauri android build` rewrites the `getByName("debug")` block in the *generated*
|
||||
// copy of this file (src-tauri/gen/android/app/build.gradle.kts) to inject its
|
||||
// `jniLibs.keepDebugSymbols` entries -- you can see the damage in the generated
|
||||
// file, where `packaging {` ends up with the first injected line welded onto it.
|
||||
// That rewrite drops `applicationIdSuffix` and nothing else: `versionNameSuffix`
|
||||
// and the manifest placeholders beside it survive. It happens after
|
||||
// sync-android-sources.sh has copied this file into place and before Gradle
|
||||
// configures, so no amount of syncing can beat it.
|
||||
//
|
||||
// The result was a debug APK whose applicationId was plain
|
||||
// `com.dtourolle.jellytau`, colliding with a real release install:
|
||||
// INSTALL_FAILED_UPDATE_INCOMPATIBLE, with the only obvious way out being to
|
||||
// uninstall the release app and lose its data. The `sideBySideRelease` suffix in
|
||||
// the *release* build type is untouched by the same rewrite, which is why that
|
||||
// path kept working and this one did not.
|
||||
//
|
||||
// A top-level statement is not inside the block the rewriter looks for, so it
|
||||
// survives. scripts/build-android.sh asserts the built applicationId afterwards,
|
||||
// so a future CLI that reaches further fails the build instead of shipping a
|
||||
// colliding APK.
|
||||
android.buildTypes.getByName("debug").applicationIdSuffix = ".debug"
|
||||
|
||||
rust {
|
||||
rootDirRel = "../../../"
|
||||
}
|
||||
|
||||
@@ -1330,10 +1330,8 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
// Honour the viewer's system captioning preferences (size,
|
||||
// colour, edge style) and whatever styling the track carries —
|
||||
// the same defaults a PlayerView would have applied.
|
||||
setUserDefaultStyle()
|
||||
// Text over the picture, not a black bar across it. (DR-261)
|
||||
setStyle(captionStyle())
|
||||
setUserDefaultTextSize()
|
||||
}
|
||||
android.util.Log.d("JellyTauPlayer", "SubtitleView created")
|
||||
@@ -1341,6 +1339,61 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
return videoView!!.hashCode()
|
||||
}
|
||||
|
||||
/**
|
||||
* The caption style to draw cues in: the viewer's own, with the background
|
||||
* taken out.
|
||||
*
|
||||
* `setUserDefaultStyle()` reads Android's captioning preferences and falls
|
||||
* back to media3's `DEFAULT` when the viewer has set none — and that
|
||||
* default is white on **opaque black**, which is what put a black box under
|
||||
* every line, wide enough to sit across the picture.
|
||||
*
|
||||
* Dropping the box is not the same as replacing the style. A viewer who has
|
||||
* configured captions in accessibility settings has said something specific
|
||||
* about colour, typeface and edges, and overriding all of that to get a
|
||||
* transparent background would be answering a question they did not ask. So
|
||||
* their style is kept and only the two colours that paint a box —
|
||||
* background and window — are cleared.
|
||||
*
|
||||
* With no box the text supplies its own contrast or it is unreadable over a
|
||||
* bright scene, so a style that asked for no edge gets a black outline. One
|
||||
* that already specifies an edge keeps it: that viewer has already said how
|
||||
* they want their captions separated from the picture.
|
||||
*
|
||||
* TRACES: UR-020 | DR-261
|
||||
*/
|
||||
private fun captionStyle(): androidx.media3.ui.CaptionStyleCompat {
|
||||
val base = try {
|
||||
val captioning = appContext.getSystemService(Context.CAPTIONING_SERVICE)
|
||||
as? android.view.accessibility.CaptioningManager
|
||||
if (captioning != null && captioning.isEnabled) {
|
||||
androidx.media3.ui.CaptionStyleCompat.createFromCaptionStyle(captioning.userStyle)
|
||||
} else {
|
||||
androidx.media3.ui.CaptionStyleCompat.DEFAULT
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// A captioning service that refuses to answer is not a reason to
|
||||
// draw nothing; fall back to the same default media3 would use.
|
||||
android.util.Log.w("JellyTauPlayer", "Captioning preferences unavailable", e)
|
||||
androidx.media3.ui.CaptionStyleCompat.DEFAULT
|
||||
}
|
||||
|
||||
val needsOwnEdge = base.edgeType == androidx.media3.ui.CaptionStyleCompat.EDGE_TYPE_NONE
|
||||
|
||||
return androidx.media3.ui.CaptionStyleCompat(
|
||||
base.foregroundColor,
|
||||
android.graphics.Color.TRANSPARENT,
|
||||
android.graphics.Color.TRANSPARENT,
|
||||
if (needsOwnEdge) {
|
||||
androidx.media3.ui.CaptionStyleCompat.EDGE_TYPE_OUTLINE
|
||||
} else {
|
||||
base.edgeType
|
||||
},
|
||||
if (needsOwnEdge) android.graphics.Color.BLACK else base.edgeColor,
|
||||
base.typeface
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The view that draws subtitle cues, for VideoOverlayManager to attach
|
||||
* directly above the video and below the WebView. Null before the first
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+253
-16
@@ -2251,6 +2251,12 @@ impl PlayerController {
|
||||
return Ok(Some((current_repo_item, next.clone())));
|
||||
} else {
|
||||
log::info!("[PlayerController] Current episode is the last in the season");
|
||||
if let Some(next) = self
|
||||
.first_episode_of_next_season(¤t_repo_item, repo)
|
||||
.await
|
||||
{
|
||||
return Ok(Some((current_repo_item, next)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::info!(
|
||||
@@ -2266,6 +2272,97 @@ impl PlayerController {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// The first episode of the season after this one, if the series has one.
|
||||
///
|
||||
/// A season boundary is not the end of a series, and stopping there is felt
|
||||
/// most sharply on the background-audio path (UR-040): the screen is locked,
|
||||
/// nothing shows a "next" button, and playback simply stops mid-binge. Every
|
||||
/// other autoplay entry point shares this lookup, so foreground video and
|
||||
/// the Android native path cross the boundary too.
|
||||
///
|
||||
/// Lookup failures degrade to `None` rather than an error: the episode has
|
||||
/// already finished, and the caller's only alternative is to stop anyway.
|
||||
///
|
||||
/// TRACES: UR-023, UR-040 | DR-263 | UT-238
|
||||
async fn first_episode_of_next_season(
|
||||
&self,
|
||||
current: &crate::repository::types::MediaItem,
|
||||
repo: &Arc<dyn crate::repository::MediaRepository>,
|
||||
) -> Option<crate::repository::types::MediaItem> {
|
||||
use crate::repository::types::GetItemsOptions;
|
||||
|
||||
let series_id = current.series_id.as_deref()?;
|
||||
let season_id = current.season_id.as_deref()?;
|
||||
|
||||
let season_options = GetItemsOptions {
|
||||
sort_by: Some("IndexNumber".to_string()),
|
||||
sort_order: Some("Ascending".to_string()),
|
||||
limit: Some(500),
|
||||
include_item_types: Some(vec!["Season".to_string()]),
|
||||
..Default::default()
|
||||
};
|
||||
let mut seasons = match repo.get_items(series_id, Some(season_options)).await {
|
||||
Ok(result) => result.items,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[PlayerController] Season lookup failed for series {}: {}",
|
||||
series_id,
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// Same client-side sort as the episode list: the offline repository
|
||||
// ignores sort_by and orders by sort_name instead.
|
||||
seasons.sort_by_key(|s| s.index_number.unwrap_or(i32::MAX));
|
||||
|
||||
let current_idx = seasons.iter().position(|s| s.id == season_id)?;
|
||||
|
||||
for season in &seasons[current_idx + 1..] {
|
||||
// Never roll into Specials. Jellyfin numbers them 0, so they sort
|
||||
// ahead of season 1 and are normally unreachable from here -- but a
|
||||
// server that leaves the index unset sorts them last, right where
|
||||
// this walk would otherwise land.
|
||||
if season.index_number == Some(0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let episode_options = GetItemsOptions {
|
||||
sort_by: Some("IndexNumber".to_string()),
|
||||
sort_order: Some("Ascending".to_string()),
|
||||
limit: Some(500),
|
||||
include_item_types: Some(vec!["Episode".to_string()]),
|
||||
..Default::default()
|
||||
};
|
||||
let mut episodes = match repo.get_items(&season.id, Some(episode_options)).await {
|
||||
Ok(result) => result.items,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[PlayerController] Episode lookup failed for season {}: {}",
|
||||
season.id,
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
|
||||
|
||||
// An empty season is a gap in the series, not the end of it.
|
||||
if let Some(first) = episodes.into_iter().next() {
|
||||
log::info!(
|
||||
"[PlayerController] Rolling over to {} of {}: {}",
|
||||
first.name,
|
||||
season.name,
|
||||
first.id
|
||||
);
|
||||
return Some(first);
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("[PlayerController] No further season to roll over into");
|
||||
None
|
||||
}
|
||||
|
||||
/// Start autoplay countdown thread
|
||||
pub fn start_autoplay_countdown(
|
||||
&self,
|
||||
@@ -3685,23 +3782,67 @@ mod tests {
|
||||
/// lookup tests. Only `get_item` and `get_items` are used by
|
||||
/// `fetch_next_episode_for_item`; everything else is unreachable.
|
||||
struct MockEpisodeRepo {
|
||||
episodes: Vec<repo_types::MediaItem>,
|
||||
/// Seasons in the order the series lists them, each with its episodes.
|
||||
seasons: Vec<(repo_types::MediaItem, Vec<repo_types::MediaItem>)>,
|
||||
}
|
||||
|
||||
impl MockEpisodeRepo {
|
||||
/// A one-season series, whose episodes keep the historical `ep{n}` ids.
|
||||
fn season(count: usize) -> Self {
|
||||
let episodes = (1..=count)
|
||||
.map(|i| {
|
||||
let mut item = make_repo_episode(&format!("ep{}", i), i as i32);
|
||||
item.name = format!("Episode {}", i);
|
||||
item
|
||||
Self::series(&[count])
|
||||
}
|
||||
|
||||
/// A series whose seasons hold the given episode counts. Season 1 keeps
|
||||
/// the `ep{n}` ids the single-season tests use; later seasons get
|
||||
/// `s{season}e{n}` so a rollover assertion names the season it landed in.
|
||||
fn series(counts: &[usize]) -> Self {
|
||||
let seasons = counts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(s, count)| {
|
||||
let season_number = s as i32 + 1;
|
||||
let episodes = (1..=*count)
|
||||
.map(|i| {
|
||||
let id = if season_number == 1 {
|
||||
format!("ep{}", i)
|
||||
} else {
|
||||
format!("s{}e{}", season_number, i)
|
||||
};
|
||||
make_repo_episode_in(season_number, &id, i as i32)
|
||||
})
|
||||
.collect();
|
||||
(make_repo_season(season_number), episodes)
|
||||
})
|
||||
.collect();
|
||||
Self { episodes }
|
||||
Self { seasons }
|
||||
}
|
||||
|
||||
fn all_episodes(&self) -> impl Iterator<Item = &repo_types::MediaItem> {
|
||||
self.seasons.iter().flat_map(|(_, eps)| eps.iter())
|
||||
}
|
||||
}
|
||||
|
||||
fn make_repo_season(index: i32) -> repo_types::MediaItem {
|
||||
repo_types::MediaItem {
|
||||
id: format!("season{}", index),
|
||||
name: format!("Season {}", index),
|
||||
item_type: "Season".to_string(),
|
||||
kind: crate::domain::MediaKind::Season,
|
||||
is_folder: true,
|
||||
parent_id: Some("series1".to_string()),
|
||||
index_number: Some(index),
|
||||
season_id: None,
|
||||
season_name: None,
|
||||
parent_index_number: None,
|
||||
..make_repo_episode(&format!("season{}", index), index)
|
||||
}
|
||||
}
|
||||
|
||||
fn make_repo_episode(id: &str, index: i32) -> repo_types::MediaItem {
|
||||
make_repo_episode_in(1, id, index)
|
||||
}
|
||||
|
||||
fn make_repo_episode_in(season_number: i32, id: &str, index: i32) -> repo_types::MediaItem {
|
||||
repo_types::MediaItem {
|
||||
id: id.to_string(),
|
||||
name: format!("Episode {}", index),
|
||||
@@ -3709,7 +3850,7 @@ mod tests {
|
||||
kind: crate::domain::MediaKind::Episode,
|
||||
is_folder: false,
|
||||
server_id: "server".to_string(),
|
||||
parent_id: Some("season1".to_string()),
|
||||
parent_id: Some(format!("season{}", season_number)),
|
||||
library_id: None,
|
||||
overview: None,
|
||||
genres: None,
|
||||
@@ -3731,9 +3872,9 @@ mod tests {
|
||||
index_number: Some(index),
|
||||
series_id: Some("series1".to_string()),
|
||||
series_name: Some("Test Series".to_string()),
|
||||
season_id: Some("season1".to_string()),
|
||||
season_name: Some("Season 1".to_string()),
|
||||
parent_index_number: Some(1),
|
||||
season_id: Some(format!("season{}", season_number)),
|
||||
season_name: Some(format!("Season {}", season_number)),
|
||||
parent_index_number: Some(season_number),
|
||||
user_data: None,
|
||||
media_streams: None,
|
||||
media_sources: None,
|
||||
@@ -3751,18 +3892,29 @@ mod tests {
|
||||
parent_id: &str,
|
||||
_options: Option<repo_types::GetItemsOptions>,
|
||||
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||
assert_eq!(parent_id, "season1", "episode lookup must query the season");
|
||||
// The series lists its seasons; a season lists its episodes. Both
|
||||
// are real lookups the autoplay path makes -- the second only once
|
||||
// the first has told it which season comes next.
|
||||
let items = if parent_id == "series1" {
|
||||
self.seasons.iter().map(|(s, _)| s.clone()).collect()
|
||||
} else {
|
||||
self.seasons
|
||||
.iter()
|
||||
.find(|(season, _)| season.id == parent_id)
|
||||
.map(|(_, eps)| eps.clone())
|
||||
.unwrap_or_else(|| panic!("unexpected lookup of container {}", parent_id))
|
||||
};
|
||||
let total_record_count = items.len();
|
||||
Ok(repo_types::SearchResult {
|
||||
items: self.episodes.clone(),
|
||||
total_record_count: self.episodes.len(),
|
||||
items,
|
||||
total_record_count,
|
||||
})
|
||||
}
|
||||
async fn get_item(
|
||||
&self,
|
||||
item_id: &str,
|
||||
) -> Result<repo_types::MediaItem, repo_types::RepoError> {
|
||||
self.episodes
|
||||
.iter()
|
||||
self.all_episodes()
|
||||
.find(|e| e.id == item_id)
|
||||
.cloned()
|
||||
.ok_or(repo_types::RepoError::NotFound {
|
||||
@@ -4004,6 +4156,91 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A season boundary is not the end of the series. The lookup used to stop
|
||||
/// dead at the last episode of a season, which on Android's background-audio
|
||||
/// path is felt as playback simply pausing at the end of an episode with the
|
||||
/// screen locked and nothing to un-pause it.
|
||||
///
|
||||
/// TRACES: UR-023, UR-040 | DR-263 | UT-238
|
||||
#[tokio::test]
|
||||
async fn test_next_episode_rolls_over_to_the_next_season() {
|
||||
let controller = PlayerController::default();
|
||||
let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::series(&[2, 2]));
|
||||
|
||||
let decision = controller
|
||||
.on_video_playback_ended("ep2", repo)
|
||||
.await
|
||||
.expect("decision should succeed");
|
||||
|
||||
match decision {
|
||||
AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
|
||||
assert_eq!(
|
||||
next_episode.id, "s2e1",
|
||||
"the first episode of the next season follows the last of this one"
|
||||
);
|
||||
}
|
||||
other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// A season with nothing in it is not the end of the series either.
|
||||
///
|
||||
/// TRACES: UR-023 | DR-263 | UT-238
|
||||
#[tokio::test]
|
||||
async fn test_next_episode_skips_an_empty_season() {
|
||||
let controller = PlayerController::default();
|
||||
let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::series(&[1, 0, 1]));
|
||||
|
||||
let decision = controller
|
||||
.on_video_playback_ended("ep1", repo)
|
||||
.await
|
||||
.expect("decision should succeed");
|
||||
|
||||
match decision {
|
||||
AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
|
||||
assert_eq!(next_episode.id, "s3e1");
|
||||
}
|
||||
other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// The rollover must not out-rank the sleep timer: crossing a season
|
||||
/// boundary is still a track boundary, and that is exactly where a timer set
|
||||
/// to "end of episode" is supposed to stop.
|
||||
///
|
||||
/// TRACES: UR-023, UR-026 | DR-263 | UT-238
|
||||
#[tokio::test]
|
||||
async fn test_sleep_timer_still_stops_at_a_season_boundary() {
|
||||
let controller = PlayerController::default();
|
||||
controller.set_repository(Arc::new(MockEpisodeRepo::series(&[1, 1])));
|
||||
controller.set_sleep_timer(SleepTimerMode::Episodes { remaining: 1 });
|
||||
|
||||
let episode = MediaItem {
|
||||
transport: None,
|
||||
media_type: MediaType::Audio,
|
||||
item_type: Some("Episode".to_string()),
|
||||
series_id: Some("series1".to_string()),
|
||||
duration: Some(180.0),
|
||||
source: MediaSource::Remote {
|
||||
stream_url: "http://example.com/ep1.mp3".to_string(),
|
||||
jellyfin_item_id: "ep1".to_string(),
|
||||
},
|
||||
..create_test_items(1).remove(0)
|
||||
};
|
||||
controller.play_queue(vec![episode], 0).unwrap();
|
||||
// Played through to the end -- a natural finish, not a stream cut short.
|
||||
controller.seek(180.0).unwrap();
|
||||
controller.take_end_reason();
|
||||
|
||||
let decision = controller.on_playback_ended().await.unwrap();
|
||||
|
||||
assert!(
|
||||
matches!(decision, AutoplayDecision::Stop),
|
||||
"the last episode the timer allows must stop, not roll into season 2 (got {:?})",
|
||||
decision
|
||||
);
|
||||
}
|
||||
|
||||
/// Last episode of the season: no popup, stop.
|
||||
#[tokio::test]
|
||||
async fn test_video_playback_ended_last_episode_stops() {
|
||||
|
||||
@@ -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()
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -684,26 +684,33 @@ impl OfflineRepository {
|
||||
/// The position half is what makes cross-device resume work: the resume
|
||||
/// check reads this table alone, so before it was mirrored an item watched
|
||||
/// elsewhere resumed from whatever *this* device last saw, or not at all.
|
||||
/// The played flag rides along for the same reason: nothing else writes it
|
||||
/// but an explicit local toggle, so a cached episode list read every
|
||||
/// episode back as unwatched — the list the season view ticks and the one
|
||||
/// `pick_current_episode` reads to decide what is up next (DR-264).
|
||||
///
|
||||
/// TRACES: UR-025, UR-069 | DR-114, DR-155 | UT-102, UT-152
|
||||
/// TRACES: UR-025, UR-062, UR-069 | DR-114, DR-155, DR-264 | UT-102, UT-152, UT-240
|
||||
async fn mirror_user_data(&self, item: &MediaItem, now: &str) -> Result<(), RepoError> {
|
||||
let user_data = item.user_data.as_ref();
|
||||
let is_favorite = user_data.and_then(|ud| ud.is_favorite);
|
||||
let position_ticks = user_data.and_then(|ud| ud.playback_position_ticks);
|
||||
let is_played = user_data.and_then(|ud| ud.is_played);
|
||||
|
||||
// Nothing the server actually told us about — do not invent a row.
|
||||
if is_favorite.is_none() && position_ticks.is_none() {
|
||||
if is_favorite.is_none() && position_ticks.is_none() && is_played.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let query = Query::with_params(
|
||||
"INSERT INTO user_data
|
||||
(user_id, item_id, is_favorite, playback_position_ticks, synced_at, pending_sync)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, 0)
|
||||
(user_id, item_id, is_favorite, playback_position_ticks, is_played,
|
||||
synced_at, pending_sync)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)
|
||||
ON CONFLICT(user_id, item_id) DO UPDATE SET
|
||||
is_favorite = COALESCE(excluded.is_favorite, user_data.is_favorite),
|
||||
playback_position_ticks = COALESCE(
|
||||
excluded.playback_position_ticks, user_data.playback_position_ticks),
|
||||
is_played = COALESCE(excluded.is_played, user_data.is_played),
|
||||
synced_at = excluded.synced_at
|
||||
WHERE user_data.pending_sync = 0",
|
||||
vec![
|
||||
@@ -715,6 +722,9 @@ impl OfflineRepository {
|
||||
position_ticks
|
||||
.map(QueryParam::Int64)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
is_played
|
||||
.map(|p| QueryParam::Int(if p { 1 } else { 0 }))
|
||||
.unwrap_or(QueryParam::Null),
|
||||
QueryParam::String(now.to_string()),
|
||||
],
|
||||
);
|
||||
@@ -4786,6 +4796,98 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// UT-240 — the server's *played* flag is mirrored locally, so an episode
|
||||
/// watched anywhere is watched here.
|
||||
///
|
||||
/// The mirror carried only the favourite flag and the position, so
|
||||
/// `user_data.is_played` was written by nothing but an explicit local
|
||||
/// toggle: a cached episode list reported every episode as unwatched, which
|
||||
/// is the list `pick_current_episode` reads to decide what is up next
|
||||
/// (DR-264), and the list the season view ticks.
|
||||
///
|
||||
/// TRACES: UR-025, UR-062 | DR-264 | UT-240
|
||||
#[tokio::test]
|
||||
async fn test_save_to_cache_mirrors_played_flag_without_clobbering_pending() {
|
||||
use crate::storage::db_service::DatabaseService;
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(
|
||||
db_service.clone(),
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
|
||||
let played_flag = |id: &'static str| {
|
||||
let db = db_service.clone();
|
||||
async move {
|
||||
db.query_optional(
|
||||
Query::with_params(
|
||||
"SELECT is_played, pending_sync FROM user_data \
|
||||
WHERE user_id = ? AND item_id = ?",
|
||||
vec![
|
||||
QueryParam::String("test-user".to_string()),
|
||||
QueryParam::String(id.to_string()),
|
||||
],
|
||||
),
|
||||
|row| Ok((row.get::<_, Option<i32>>(0)?, row.get::<_, Option<i32>>(1)?)),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
};
|
||||
|
||||
// Watched to the end on another client.
|
||||
let mut watched = create_test_item("ep-4", "Watched Elsewhere", None);
|
||||
watched.user_data = Some(UserData {
|
||||
is_played: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
// No user data at all — must not fabricate an "unwatched" record.
|
||||
let untouched = create_test_item("ep-5", "No User Data", None);
|
||||
|
||||
repo.save_to_cache("parent-1", &[watched, untouched])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
played_flag("ep-4").await,
|
||||
Some((Some(1), Some(0))),
|
||||
"the server's played flag should be mirrored as synced"
|
||||
);
|
||||
assert_eq!(
|
||||
played_flag("ep-5").await,
|
||||
None,
|
||||
"an item without UserData should not get an invented played flag"
|
||||
);
|
||||
|
||||
// Marked unwatched here while the server was unreachable.
|
||||
db_service
|
||||
.execute(Query::with_params(
|
||||
"UPDATE user_data SET is_played = 0, pending_sync = 1 \
|
||||
WHERE user_id = ? AND item_id = ?",
|
||||
vec![
|
||||
QueryParam::String("test-user".to_string()),
|
||||
QueryParam::String("ep-4".to_string()),
|
||||
],
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut still_played = create_test_item("ep-4", "Watched Elsewhere", None);
|
||||
still_played.user_data = Some(UserData {
|
||||
is_played: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
repo.save_to_cache("parent-1", &[still_played])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
played_flag("ep-4").await,
|
||||
Some((Some(0), Some(1))),
|
||||
"an unsynced local toggle must survive a cache write"
|
||||
);
|
||||
}
|
||||
|
||||
/// UT-152 — a server item carrying *only* a position (no favourite flag)
|
||||
/// still gets mirrored.
|
||||
///
|
||||
|
||||
@@ -1306,6 +1306,117 @@ fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usi
|
||||
)
|
||||
}
|
||||
|
||||
/// How many rows to ask the server for, given how many the row will show.
|
||||
///
|
||||
/// Collapsing only ever shrinks a listing, so a request for exactly the number
|
||||
/// of cards the row shows can come back as a handful after one freshly-ripped
|
||||
/// album folds its tracks together. Over-fetch and truncate after collapsing.
|
||||
///
|
||||
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-244
|
||||
fn latest_items_fetch_limit(limit: usize) -> usize {
|
||||
limit.saturating_mul(3)
|
||||
}
|
||||
|
||||
/// Collapse newly-added *tracks* into the album they belong to.
|
||||
///
|
||||
/// `GroupItems=true` asks Jellyfin to do this server-side, but it only groups a
|
||||
/// track whose parent chain actually resolves a `MusicAlbum`, and older servers
|
||||
/// ignore the parameter outright — so "Recently Added" still filled up with one
|
||||
/// card per song of a single import. Grouping again here makes the row's shape
|
||||
/// a property of this app rather than of the server it is talking to.
|
||||
///
|
||||
/// Rules: a track collapses only when it names an `album_id` (without one there
|
||||
/// is no album to open, so a standalone track stays a track); if the server did
|
||||
/// return the album row itself, that row wins and its tracks are dropped; the
|
||||
/// album takes the position of the first of its tracks, so recency order
|
||||
/// survives. Everything else — movies, episodes, folders — passes through
|
||||
/// untouched.
|
||||
///
|
||||
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-241, UT-242, UT-243
|
||||
fn collapse_tracks_into_albums(items: Vec<MediaItem>) -> Vec<MediaItem> {
|
||||
use std::collections::HashSet;
|
||||
|
||||
// Albums the server already returned in their own right: their tracks are
|
||||
// redundant, and the real row carries detail a stand-in cannot.
|
||||
let server_albums: HashSet<String> = items
|
||||
.iter()
|
||||
.filter(|i| i.kind == crate::domain::MediaKind::Album)
|
||||
.map(|i| i.id.clone())
|
||||
.collect();
|
||||
|
||||
let mut seen_albums: HashSet<String> = HashSet::new();
|
||||
let mut collapsed = Vec::with_capacity(items.len());
|
||||
|
||||
for item in items {
|
||||
let album_id = match (&item.kind, &item.album_id) {
|
||||
(crate::domain::MediaKind::Track, Some(id)) => id.clone(),
|
||||
_ => {
|
||||
collapsed.push(item);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if server_albums.contains(&album_id) || !seen_albums.insert(album_id.clone()) {
|
||||
continue;
|
||||
}
|
||||
collapsed.push(album_from_track(&item, album_id));
|
||||
}
|
||||
|
||||
collapsed
|
||||
}
|
||||
|
||||
/// Build the album card a collapsed group of tracks stands for.
|
||||
///
|
||||
/// The track's own artwork tag is reused: Jellyfin serves an item's primary
|
||||
/// image by id and treats the tag as a cache key, and an embedded-art track
|
||||
/// carries the album cover anyway.
|
||||
///
|
||||
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-241
|
||||
fn album_from_track(track: &MediaItem, album_id: String) -> MediaItem {
|
||||
MediaItem {
|
||||
id: album_id,
|
||||
name: track
|
||||
.album_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Unknown Album".to_string()),
|
||||
item_type: "MusicAlbum".to_string(),
|
||||
kind: crate::domain::MediaKind::Album,
|
||||
is_folder: true,
|
||||
server_id: track.server_id.clone(),
|
||||
parent_id: None,
|
||||
library_id: track.library_id.clone(),
|
||||
overview: None,
|
||||
genres: track.genres.clone(),
|
||||
production_year: track.production_year,
|
||||
premiere_date: track.premiere_date.clone(),
|
||||
community_rating: None,
|
||||
official_rating: None,
|
||||
// A track's duration says nothing about the album's, and its track
|
||||
// number, album link and streams belong to the leaf alone.
|
||||
runtime_ticks: None,
|
||||
duration_ms: None,
|
||||
primary_image_tag: track.primary_image_tag.clone(),
|
||||
image_id: track.image_id.clone(),
|
||||
backdrop_image_tags: track.backdrop_image_tags.clone(),
|
||||
parent_backdrop_image_tags: track.parent_backdrop_image_tags.clone(),
|
||||
album_id: None,
|
||||
album_name: None,
|
||||
album_artist: track.album_artist.clone(),
|
||||
artists: track.artists.clone(),
|
||||
artist_items: track.artist_items.clone(),
|
||||
index_number: None,
|
||||
parent_index_number: None,
|
||||
series_id: None,
|
||||
series_name: None,
|
||||
season_id: None,
|
||||
season_name: None,
|
||||
user_data: None,
|
||||
media_streams: None,
|
||||
media_sources: None,
|
||||
people: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the Jellyfin endpoint for a Next Up listing.
|
||||
///
|
||||
/// `EnableResumable=false` is the point of this query: the server default is
|
||||
@@ -1755,18 +1866,34 @@ impl MediaRepository for OnlineRepository {
|
||||
Ok(media_item)
|
||||
}
|
||||
|
||||
/// Recently Added, one card per thing that was added.
|
||||
///
|
||||
/// The server is asked to group (`GroupItems=true`) *and* the answer is
|
||||
/// grouped again here — see `collapse_tracks_into_albums` for why trusting
|
||||
/// the server alone left the row full of one album's songs.
|
||||
///
|
||||
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-241, UT-244
|
||||
async fn get_latest_items(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let endpoint = build_latest_items_endpoint(&self.user_id, parent_id, limit);
|
||||
let limit_val = limit.unwrap_or(16);
|
||||
let endpoint = build_latest_items_endpoint(
|
||||
&self.user_id,
|
||||
parent_id,
|
||||
Some(latest_items_fetch_limit(limit_val)),
|
||||
);
|
||||
|
||||
let items: Vec<JellyfinItem> = self.get_json(&endpoint).await?;
|
||||
Ok(items
|
||||
let items = items
|
||||
.into_iter()
|
||||
.map(|item| item.into_media_item(self.user_id.clone()))
|
||||
.collect())
|
||||
.collect();
|
||||
|
||||
let mut collapsed = collapse_tracks_into_albums(items);
|
||||
collapsed.truncate(limit_val);
|
||||
Ok(collapsed)
|
||||
}
|
||||
|
||||
/// Continue Watching: the items this user has started and not finished.
|
||||
@@ -4122,6 +4249,135 @@ mod tests {
|
||||
assert!(endpoint.contains("Limit=16"));
|
||||
}
|
||||
|
||||
/// Build a `MediaItem` the way a real listing does — through the Jellyfin
|
||||
/// payload — so the fixtures cannot drift from the parsed shape.
|
||||
fn item_from_json(json: &str) -> MediaItem {
|
||||
let parsed: JellyfinItem = serde_json::from_str(json).expect("fixture must parse");
|
||||
parsed.into_media_item("srv".to_string())
|
||||
}
|
||||
|
||||
fn track(id: &str, name: &str, album_id: Option<&str>) -> MediaItem {
|
||||
let album = match album_id {
|
||||
Some(a) => format!(r#""AlbumId": "{a}", "Album": "Kind of Blue","#),
|
||||
None => String::new(),
|
||||
};
|
||||
item_from_json(&format!(
|
||||
r#"{{
|
||||
"Id": "{id}",
|
||||
"Name": "{name}",
|
||||
"Type": "Audio",
|
||||
{album}
|
||||
"ImageTags": {{"Primary": "art-{id}"}},
|
||||
"AlbumArtist": "Miles Davis",
|
||||
"Artists": ["Miles Davis"],
|
||||
"IndexNumber": 1,
|
||||
"RunTimeTicks": 1000
|
||||
}}"#
|
||||
))
|
||||
}
|
||||
|
||||
/// A newly-imported album must read as *one* new album, not one new song
|
||||
/// per track — even when the server hands back the raw leaves despite
|
||||
/// `GroupItems=true` (older servers, and libraries whose tracks resolve no
|
||||
/// album parent, ignore it).
|
||||
///
|
||||
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-241
|
||||
#[test]
|
||||
fn test_collapse_tracks_into_albums_shows_one_card_per_album() {
|
||||
let movie = item_from_json(
|
||||
r#"{"Id": "mov-1", "Name": "Heat", "Type": "Movie", "ImageTags": {"Primary": "art-mov"}}"#,
|
||||
);
|
||||
let items = vec![
|
||||
track("trk-1", "So What", Some("alb-1")),
|
||||
track("trk-2", "Blue in Green", Some("alb-1")),
|
||||
movie,
|
||||
track("trk-3", "Flamenco Sketches", Some("alb-1")),
|
||||
];
|
||||
|
||||
let collapsed = collapse_tracks_into_albums(items);
|
||||
|
||||
assert_eq!(
|
||||
collapsed.len(),
|
||||
2,
|
||||
"three tracks of one album plus a movie must read as two cards, got: {:?}",
|
||||
collapsed.iter().map(|i| &i.name).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
let album = &collapsed[0];
|
||||
assert_eq!(album.id, "alb-1", "the card must open the album");
|
||||
assert_eq!(album.name, "Kind of Blue");
|
||||
assert_eq!(album.item_type, "MusicAlbum");
|
||||
assert_eq!(album.kind, crate::domain::MediaKind::Album);
|
||||
assert!(album.is_folder);
|
||||
assert_eq!(album.album_artist.as_deref(), Some("Miles Davis"));
|
||||
assert!(album.image_id.is_some(), "album card needs artwork");
|
||||
// Track-only detail must not ride along on a container.
|
||||
assert!(album.index_number.is_none());
|
||||
assert!(album.album_id.is_none());
|
||||
assert!(album.runtime_ticks.is_none());
|
||||
|
||||
// The movie keeps its place after the album its tracks stood in front of.
|
||||
assert_eq!(collapsed[1].id, "mov-1");
|
||||
}
|
||||
|
||||
/// When the server *did* group, its own album row wins — the tracks it also
|
||||
/// returned must not add a second card for the same album.
|
||||
///
|
||||
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-242
|
||||
#[test]
|
||||
fn test_collapse_prefers_the_album_row_the_server_returned() {
|
||||
let album = item_from_json(
|
||||
r#"{"Id": "alb-1", "Name": "Kind of Blue", "Type": "MusicAlbum", "IsFolder": true,
|
||||
"Overview": "1959", "ImageTags": {"Primary": "art-alb"}}"#,
|
||||
);
|
||||
let items = vec![
|
||||
album,
|
||||
track("trk-1", "So What", Some("alb-1")),
|
||||
track("trk-2", "Blue in Green", Some("alb-1")),
|
||||
];
|
||||
|
||||
let collapsed = collapse_tracks_into_albums(items);
|
||||
|
||||
assert_eq!(collapsed.len(), 1, "one album, one card");
|
||||
assert_eq!(collapsed[0].id, "alb-1");
|
||||
assert_eq!(
|
||||
collapsed[0].overview.as_deref(),
|
||||
Some("1959"),
|
||||
"the server's own album row must survive, not a track-built stand-in"
|
||||
);
|
||||
}
|
||||
|
||||
/// A track with no album has no container to collapse into, so it stays —
|
||||
/// same reasoning that leaves movies alone.
|
||||
///
|
||||
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-243
|
||||
#[test]
|
||||
fn test_collapse_leaves_a_standalone_track_alone() {
|
||||
let items = vec![track("trk-1", "Field Recording", None)];
|
||||
|
||||
let collapsed = collapse_tracks_into_albums(items);
|
||||
|
||||
assert_eq!(collapsed.len(), 1);
|
||||
assert_eq!(collapsed[0].id, "trk-1");
|
||||
assert_eq!(collapsed[0].item_type, "Audio");
|
||||
}
|
||||
|
||||
/// Collapsing shrinks the listing, so the request has to over-fetch: asking
|
||||
/// for exactly 16 rows and then folding one 14-track album into them leaves
|
||||
/// an almost empty "Recently Added".
|
||||
///
|
||||
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-244
|
||||
#[test]
|
||||
fn test_latest_items_over_fetches_before_collapsing() {
|
||||
assert!(
|
||||
latest_items_fetch_limit(16) > 16,
|
||||
"must ask for more rows than the row shows"
|
||||
);
|
||||
let endpoint =
|
||||
build_latest_items_endpoint("u1", "lib-1", Some(latest_items_fetch_limit(16)));
|
||||
assert!(endpoint.contains(&format!("Limit={}", latest_items_fetch_limit(16))));
|
||||
}
|
||||
|
||||
/// UT-190 — Next Up asks the server to leave resumable episodes out.
|
||||
///
|
||||
/// Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! I/O half (`fetch_series_episodes`, `resolve_current_episode`) so the policy
|
||||
//! can be unit-tested without standing up a repository.
|
||||
//!
|
||||
//! TRACES: UR-062 | DR-101
|
||||
//! TRACES: UR-062 | DR-101, DR-264 | UT-239
|
||||
|
||||
use super::{GetItemsOptions, MediaItem, MediaRepository, RepoError};
|
||||
|
||||
@@ -83,6 +83,34 @@ fn is_played(item: &MediaItem) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Has the viewer reached the end of this episode?
|
||||
///
|
||||
/// The played *flag* is not enough. Nothing records completion locally — the
|
||||
/// stop report writes a position, and the cache mirror carries the server's
|
||||
/// flag only on the next refresh — so within seconds of an episode ending the
|
||||
/// only local evidence that it is over is its position, parked at the very end
|
||||
/// of its runtime. Leaving the player with Back reloads the series page inside
|
||||
/// that window (DR-264).
|
||||
fn is_finished(item: &MediaItem) -> bool {
|
||||
if is_played(item) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let Some(user_data) = item.user_data.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let position_ms = user_data
|
||||
.playback_position_ms
|
||||
.or_else(|| user_data.playback_position_ticks.map(|t| t / 10_000))
|
||||
.unwrap_or(0);
|
||||
// Without a duration a position says nothing about how much is left.
|
||||
let Some(duration_ms) = item.duration_ms.filter(|d| *d > 0) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
position_ms as f64 / duration_ms as f64 >= MAX_PROGRESS_FRACTION
|
||||
}
|
||||
|
||||
fn belongs_to_series(item: &MediaItem, series_id: &str) -> bool {
|
||||
item.series_id.as_deref() == Some(series_id)
|
||||
}
|
||||
@@ -130,11 +158,20 @@ pub fn pick_current_episode(
|
||||
return Some(found.clone());
|
||||
}
|
||||
|
||||
// 2. Next Up for this series.
|
||||
if let Some(found) = next_up
|
||||
.iter()
|
||||
.find(|e| e.series_id.is_none() || belongs_to_series(e, series_id))
|
||||
{
|
||||
// 2. Next Up for this series — unless it names an episode we can already
|
||||
// see is over. Next Up is the server's answer, and the server is one
|
||||
// stop-report behind for a moment after an episode ends; the local
|
||||
// position is not, so a finished candidate is dropped rather than handed
|
||||
// back as "up next" (DR-264).
|
||||
if let Some(found) = next_up.iter().find(|e| {
|
||||
if !(e.series_id.is_none() || belongs_to_series(e, series_id)) {
|
||||
return false;
|
||||
}
|
||||
// Judge it by the copy from `episodes` when there is one: that is the
|
||||
// copy carrying the local user-data.
|
||||
let local = episodes.iter().find(|listed| listed.id == e.id);
|
||||
!is_finished(local.unwrap_or(e))
|
||||
}) {
|
||||
// Prefer the copy from `episodes` when we have one: it carries the
|
||||
// user-data and images the list already fetched.
|
||||
let matched = episodes.iter().find(|e| e.id == found.id);
|
||||
@@ -145,7 +182,7 @@ pub fn pick_current_episode(
|
||||
// unwatched: a viewer who skipped the pilot but is deep into season 3
|
||||
// must not be dragged back to S1E1. An earlier gap is a deliberate skip;
|
||||
// where they stopped is the *last* thing they watched.
|
||||
if let Some(furthest) = episodes.iter().rposition(is_played) {
|
||||
if let Some(furthest) = episodes.iter().rposition(is_finished) {
|
||||
if let Some(found) = episodes.get(furthest + 1) {
|
||||
return Some(found.clone());
|
||||
}
|
||||
@@ -153,7 +190,7 @@ pub fn pick_current_episode(
|
||||
|
||||
// Nothing watched yet (or the furthest-watched episode is the finale):
|
||||
// the first unwatched episode in series order.
|
||||
if let Some(found) = episodes.iter().find(|e| !is_played(e)) {
|
||||
if let Some(found) = episodes.iter().find(|e| !is_finished(e)) {
|
||||
return Some(found.clone());
|
||||
}
|
||||
|
||||
@@ -427,6 +464,41 @@ mod tests {
|
||||
assert_eq!(current.id, "s2e1");
|
||||
}
|
||||
|
||||
/// The episode the viewer just finished must not still be "up next".
|
||||
///
|
||||
/// Leaving the player with Back reloads the series page within a second of
|
||||
/// the stop report, and Jellyfin's Next Up can still name the episode that
|
||||
/// just ended. Locally we know better: the position sits at the very end of
|
||||
/// its runtime.
|
||||
///
|
||||
/// TRACES: UR-062 | DR-264 | UT-239
|
||||
#[test]
|
||||
fn a_just_finished_episode_is_not_current_even_when_next_up_still_names_it() {
|
||||
let mut eps = season(1, 5);
|
||||
eps[0] = watched(eps[0].clone());
|
||||
// Just finished: the position is at the end, the flag has not landed.
|
||||
eps[1] = in_progress(eps[1].clone(), 0.99);
|
||||
// The server has not caught up with the stop report.
|
||||
let next_up = vec![episode("s1e2", 1, 2)];
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e3");
|
||||
}
|
||||
|
||||
/// The same, offline: with no Next Up to lean on, an episode watched to the
|
||||
/// end counts as watched when scanning for the furthest-watched one.
|
||||
///
|
||||
/// TRACES: UR-062 | DR-264 | UT-239
|
||||
#[test]
|
||||
fn an_episode_watched_to_the_end_counts_as_watched_offline() {
|
||||
let mut eps = season(1, 5);
|
||||
eps[0] = watched(eps[0].clone());
|
||||
eps[1] = in_progress(eps[1].clone(), 0.99);
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_never_watched_series_opens_on_its_premiere() {
|
||||
let eps = [season(2, 3), season(1, 3)].concat();
|
||||
|
||||
@@ -28,6 +28,7 @@ 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),
|
||||
];
|
||||
|
||||
/// Initial schema migration
|
||||
@@ -819,3 +820,250 @@ 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;
|
||||
"#;
|
||||
|
||||
#[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.2",
|
||||
"version": "0.11.5",
|
||||
"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 () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- TRACES: UR-007 | DR-007 -->
|
||||
<!-- TRACES: UR-007 | DR-007, DR-262 -->
|
||||
<script lang="ts">
|
||||
/**
|
||||
* A vertical A-Z index strip for long, alphabetically-sorted lists.
|
||||
@@ -6,8 +6,14 @@
|
||||
*
|
||||
* The parent owns the actual scrolling: it passes `availableLetters`
|
||||
* (which letters have items) and an `onJump(letter)` callback.
|
||||
*
|
||||
* The strip's floor is the scroll container's own bottom edge, measured — not
|
||||
* the viewport minus a guess at the bottom bars. See alphabetStrip.ts for why
|
||||
* that distinction is the whole bug (DR-262).
|
||||
*/
|
||||
|
||||
import { stripHeightFor } from "./alphabetStrip";
|
||||
|
||||
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
|
||||
const HASH = "#"; // bucket for names starting with a digit/symbol
|
||||
|
||||
@@ -16,43 +22,45 @@
|
||||
availableLetters: Set<string>;
|
||||
/** Called with the chosen letter when the user picks one. */
|
||||
onJump: (letter: string) => void;
|
||||
/**
|
||||
* CSS length reserved at the bottom of the viewport for the bottom nav /
|
||||
* mini-player bars. The strip stretches to fill the space between the top
|
||||
* sticky offset and this gap, so it ends just above those bars.
|
||||
*/
|
||||
bottomGap?: string;
|
||||
}
|
||||
|
||||
let { availableLetters, onJump, bottomGap = "5rem" }: Props = $props();
|
||||
let { availableLetters, onJump }: Props = $props();
|
||||
|
||||
// The strip stretches from where it sits down to just above the bottom nav /
|
||||
// mini-player bars. Those bars are pinned to the bottom of the screen, so the
|
||||
// hard floor is `window.innerHeight - bottomGap`. We measure the strip's own
|
||||
// top against that floor (clamped to non-negative) and update on scroll/resize
|
||||
// so it never slides under the bars regardless of header or platform.
|
||||
let container = $state<HTMLDivElement | null>(null);
|
||||
let stripHeight = $state(0);
|
||||
|
||||
function measure() {
|
||||
if (!container) return;
|
||||
const top = container.getBoundingClientRect().top;
|
||||
const floor = window.innerHeight - remToPx(bottomGap);
|
||||
stripHeight = Math.max(0, floor - top);
|
||||
/**
|
||||
* Nearest scrollable ancestor. Resolved by computed `overflow-y` rather than
|
||||
* by tag name: the library routes scroll in a `<main>`, but the root shell
|
||||
* scrolls in a plain `<div>`, and a `closest("main")` that misses falls back
|
||||
* to the viewport — which is exactly the too-tall strip this replaced.
|
||||
*/
|
||||
function nearestScroller(el: HTMLElement | null): HTMLElement | null {
|
||||
let node = el?.parentElement ?? null;
|
||||
while (node) {
|
||||
const overflowY = getComputedStyle(node).overflowY;
|
||||
if (overflowY === "auto" || overflowY === "scroll") return node;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function remToPx(len: string): number {
|
||||
const n = parseFloat(len);
|
||||
if (len.trim().endsWith("rem")) {
|
||||
const root = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
return n * root;
|
||||
}
|
||||
return n; // assume px otherwise
|
||||
function measure() {
|
||||
if (!container) return;
|
||||
const scroller = nearestScroller(container);
|
||||
stripHeight = stripHeightFor({
|
||||
stripTop: container.getBoundingClientRect().top,
|
||||
scrollerBottom: scroller?.getBoundingClientRect().bottom ?? null,
|
||||
viewportHeight: window.innerHeight,
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
measure();
|
||||
const scroller = container?.closest("main");
|
||||
const scroller = nearestScroller(container);
|
||||
// Observing the scroller is what makes the mini player showing or hiding
|
||||
// re-measure: it is an in-flow sibling, so the scroller resizes when it
|
||||
// appears. No store subscription and no platform guess needed.
|
||||
const ro = new ResizeObserver(measure);
|
||||
if (scroller) ro.observe(scroller);
|
||||
scroller?.addEventListener("scroll", measure, { passive: true });
|
||||
@@ -64,15 +72,6 @@
|
||||
};
|
||||
});
|
||||
|
||||
// Recompute when the reserved bottom gap changes (mini-player shows/hides).
|
||||
$effect(() => {
|
||||
// Bare read: registers `bottomGap` as a dependency of this effect. Svelte 5
|
||||
// idiom, not a stray expression.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
bottomGap;
|
||||
measure();
|
||||
});
|
||||
|
||||
const letters = $derived([HASH, ...ALPHABET]);
|
||||
let activeLetter = $state<string | null>(null);
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
import { navigateUp } from "$lib/utils/navigation";
|
||||
import { currentLibrary } from "$lib/stores/library";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { shouldShowAudioMiniPlayer } from "$lib/stores/player";
|
||||
import { isAndroid } from "$lib/stores/appState";
|
||||
import SearchBar from "$lib/components/common/SearchBar.svelte";
|
||||
import SortButtonGroup from "$lib/components/common/SortButtonGroup.svelte";
|
||||
import type { SortOption } from "$lib/components/common/SortButtonGroup.svelte";
|
||||
@@ -259,11 +257,6 @@
|
||||
const target = gridWrapper.querySelector(`[data-grid-index="${index}"]`);
|
||||
target?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
// Bottom space the layout's <main> reserves for the nav / mini-player bars.
|
||||
// Mirrors src/routes/library/+layout.svelte so the A-Z strip ends just above
|
||||
// whichever bars are visible.
|
||||
const bottomGap = $derived($shouldShowAudioMiniPlayer ? ($isAndroid ? "11rem" : "7rem") : "5rem");
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
@@ -363,7 +356,7 @@
|
||||
</div>
|
||||
{#if showAlphaBar}
|
||||
<div class="sticky top-2 self-start flex-shrink-0 h-fit">
|
||||
<AlphabetScrollBar {availableLetters} onJump={jumpToLetter} {bottomGap} />
|
||||
<AlphabetScrollBar {availableLetters} onJump={jumpToLetter} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { stripHeightFor, type StripHeightInput } from "./alphabetStrip";
|
||||
|
||||
/**
|
||||
* Regression: the A-Z jump strip ran under the mini player, so the tail of the
|
||||
* alphabet could not be tapped.
|
||||
*
|
||||
* The strip used to size itself against `window.innerHeight` minus a hardcoded
|
||||
* guess at the bottom bars' height (5rem / 7rem / 11rem, chosen by platform and
|
||||
* whether the mini player was showing). Those bars stopped being fixed overlays
|
||||
* when BottomUi became an in-flow flex sibling below the scroller, so the guess
|
||||
* has no relationship to the real stack — and it is short on any device with a
|
||||
* navigation/gesture bar, because `--safe-bottom` is padded *inside* BottomUi.
|
||||
*
|
||||
* The invariant every case below asserts: the strip must end at or above the
|
||||
* scroller's own bottom edge, which is exactly the top of the mini player.
|
||||
*
|
||||
* TRACES: UR-007 | DR-007 | UT-235, UT-236, UT-237
|
||||
*/
|
||||
|
||||
/** 800px-tall phone viewport; the library scroller starts 120px down. */
|
||||
const VIEWPORT = 800;
|
||||
const STRIP_TOP = 120;
|
||||
|
||||
/** Measured heights of the real bottom UI, in CSS px. */
|
||||
const NAV = 77; // BottomNav: py-2 + icon 24 + gap 4 + label 16 + py-2, + 1px border
|
||||
const MINI = 69; // MiniPlayer: 4px progress bar + 48px artwork row + py-2, + 1px border
|
||||
const REMOTE_ROW = 32; // "Playing on <device>" banner, remote mode only
|
||||
const GESTURE_BAR = 48; // --safe-bottom on a 3-button nav device
|
||||
|
||||
function bounds(bottomUiHeight: number): StripHeightInput {
|
||||
return {
|
||||
stripTop: STRIP_TOP,
|
||||
scrollerBottom: VIEWPORT - bottomUiHeight,
|
||||
viewportHeight: VIEWPORT,
|
||||
};
|
||||
}
|
||||
|
||||
describe("stripHeightFor", () => {
|
||||
it("keeps the last letter above the bottom nav when nothing is playing", () => {
|
||||
const input = bounds(NAV + GESTURE_BAR);
|
||||
const bottom = STRIP_TOP + stripHeightFor(input);
|
||||
|
||||
expect(bottom).toBeLessThanOrEqual(input.scrollerBottom!);
|
||||
});
|
||||
|
||||
it("keeps the last letter above the mini player while audio plays", () => {
|
||||
const input = bounds(MINI + NAV + GESTURE_BAR);
|
||||
const bottom = STRIP_TOP + stripHeightFor(input);
|
||||
|
||||
expect(bottom).toBeLessThanOrEqual(input.scrollerBottom!);
|
||||
});
|
||||
|
||||
it("survives the taller mini player of remote mode", () => {
|
||||
const input = bounds(REMOTE_ROW + MINI + NAV + GESTURE_BAR);
|
||||
const bottom = STRIP_TOP + stripHeightFor(input);
|
||||
|
||||
expect(bottom).toBeLessThanOrEqual(input.scrollerBottom!);
|
||||
});
|
||||
|
||||
it("still fills the space it does have, rather than stopping short", () => {
|
||||
const input = bounds(MINI + NAV + GESTURE_BAR);
|
||||
const available = input.scrollerBottom! - STRIP_TOP;
|
||||
|
||||
// Within one letter's worth of the space available (letters are ~16px).
|
||||
expect(stripHeightFor(input)).toBeGreaterThan(available - 16);
|
||||
});
|
||||
|
||||
it("falls back to the viewport when the strip has no scroll container", () => {
|
||||
const height = stripHeightFor({
|
||||
stripTop: STRIP_TOP,
|
||||
scrollerBottom: null,
|
||||
viewportHeight: VIEWPORT,
|
||||
});
|
||||
|
||||
expect(STRIP_TOP + height).toBeLessThanOrEqual(VIEWPORT);
|
||||
});
|
||||
|
||||
it("never returns a negative height when the strip is scrolled past the floor", () => {
|
||||
const height = stripHeightFor({
|
||||
stripTop: 900,
|
||||
scrollerBottom: 600,
|
||||
viewportHeight: VIEWPORT,
|
||||
});
|
||||
|
||||
expect(height).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Pure geometry for the A-Z jump strip (see AlphabetScrollBar.svelte).
|
||||
*
|
||||
* The strip stretches from wherever it sits down to a floor, and the floor is
|
||||
* the whole question: get it wrong and the tail of the alphabet renders past
|
||||
* the bottom of the scroller, under the mini player / bottom nav, where it
|
||||
* cannot be tapped.
|
||||
*
|
||||
* The floor is the scroller's own bottom edge — never the viewport's. The strip
|
||||
* used to size itself as `window.innerHeight` minus a hardcoded guess at the
|
||||
* bars' height (5rem/7rem/11rem by platform and mini-player visibility), which
|
||||
* dates from when those bars were `position: fixed` overlays. They are in-flow
|
||||
* flex siblings below the scroller now (see BottomUi.svelte), so the scroller's
|
||||
* bottom edge *is* the top of the mini player, measured exactly, every frame —
|
||||
* and the guess was short on every device with a navigation/gesture bar,
|
||||
* because `--safe-bottom` is padded inside BottomUi and the guess never knew
|
||||
* about it.
|
||||
*
|
||||
* Extracted from the component so the floor rule is unit-testable — the
|
||||
* component only supplies measurements.
|
||||
*
|
||||
* TRACES: UR-007 | DR-007, DR-262
|
||||
*/
|
||||
|
||||
/**
|
||||
* Breathing room left under the last letter, in px. Mirrors the `top-2` sticky
|
||||
* offset at the other end so the strip sits symmetrically in the scrollport.
|
||||
*/
|
||||
export const STRIP_BOTTOM_GAP = 8;
|
||||
|
||||
export interface StripHeightInput {
|
||||
/** Viewport-relative top of the strip container (`getBoundingClientRect().top`). */
|
||||
stripTop: number;
|
||||
/**
|
||||
* Viewport-relative bottom edge of the scroll container the strip lives in,
|
||||
* or `null` when the strip has no scrollable ancestor to measure.
|
||||
*/
|
||||
scrollerBottom: number | null;
|
||||
/** Viewport height — the fallback floor when there is no scroll container. */
|
||||
viewportHeight: number;
|
||||
/** Override for {@link STRIP_BOTTOM_GAP}, in px. */
|
||||
gap?: number;
|
||||
}
|
||||
|
||||
/** How tall the A-Z strip may be without running under the bottom bars. */
|
||||
export function stripHeightFor({
|
||||
stripTop,
|
||||
scrollerBottom,
|
||||
viewportHeight,
|
||||
gap = STRIP_BOTTOM_GAP,
|
||||
}: StripHeightInput): number {
|
||||
const floor = scrollerBottom ?? viewportHeight;
|
||||
return Math.max(0, floor - gap - stripTop);
|
||||
}
|
||||
@@ -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