Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed26eb881a | ||
|
|
6c188a2b44 | ||
|
|
9e2278080d | ||
|
|
33e1403981 | ||
|
|
9bb5b44d0f | ||
|
|
8027fd5fac | ||
|
|
f0c33a52c2 | ||
|
|
b1844f673e | ||
|
|
4bce81a800 | ||
|
|
65d3d912f7 | ||
|
|
192a8b3c67 | ||
|
|
747ec0161c | ||
|
|
bc92eb4dea | ||
|
|
c72ca86865 | ||
|
|
f9e1a8e69a | ||
|
|
d4f80a4afa | ||
|
|
7b738002a0 | ||
|
|
c41b8ec896 | ||
|
|
dea78b89b9 | ||
|
|
368935e6f4 | ||
|
|
c44070e720 | ||
|
|
8ecf74a2af | ||
|
|
5fb9c1ff3b | ||
|
|
b5a3a3b427 | ||
|
|
10d77f1380 | ||
|
|
03c0b5cd17 | ||
|
|
27a995f877 | ||
|
|
f902caa07f | ||
|
|
fb3d0014ef | ||
|
|
da762da55d |
@@ -0,0 +1,348 @@
|
|||||||
|
name: '📱 Test APK'
|
||||||
|
|
||||||
|
# Installable Android builds that are not releases.
|
||||||
|
#
|
||||||
|
# Two ways in:
|
||||||
|
#
|
||||||
|
# push to master -> refreshes the rolling `latest` pre-release, so there is
|
||||||
|
# always a current APK behind one stable URL that can be
|
||||||
|
# handed to a tester once and never re-sent.
|
||||||
|
# workflow_dispatch -> builds any branch on demand, optionally publishing it
|
||||||
|
# as `test-<branch>`.
|
||||||
|
#
|
||||||
|
# Why this is separate from build-release.yml: that workflow is tag-driven,
|
||||||
|
# builds Linux + Windows + Android and creates a real release. This produces one
|
||||||
|
# APK and never touches the release channel.
|
||||||
|
#
|
||||||
|
# What comes out installs as com.dtourolle.jellytau.debug ("JellyTau Debug"),
|
||||||
|
# side by side with a real install and with its own data directory. It is a
|
||||||
|
# fully R8-minified release build -- minification is where Android builds have
|
||||||
|
# actually broken here (R8 stripping JNI-loaded player and security classes),
|
||||||
|
# and a plain debug build cannot catch that -- but it is signed with the debug
|
||||||
|
# keystore rather than the store key. So a bad master commit can never replace
|
||||||
|
# somebody's working install, and the production signing key stays in the
|
||||||
|
# tag-driven workflow where it belongs.
|
||||||
|
#
|
||||||
|
# Getting the APK to somebody else: Gitea artifacts need an account with read
|
||||||
|
# access to download, so published builds are attached to a pre-release, whose
|
||||||
|
# assets are a plain public URL. That is the only way an outside tester gets the
|
||||||
|
# file without being given an account.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths-ignore:
|
||||||
|
- '**/*.md'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
variant:
|
||||||
|
description: 'Which build to produce'
|
||||||
|
required: true
|
||||||
|
default: 'side-by-side-release'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
# R8-minified, exactly what ships, in the debug slot.
|
||||||
|
- side-by-side-release
|
||||||
|
# Unminified. Faster, readable stack traces, but does not exercise
|
||||||
|
# minification at all.
|
||||||
|
- debug
|
||||||
|
abi:
|
||||||
|
description: 'Target ABI'
|
||||||
|
required: true
|
||||||
|
default: 'aarch64'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- aarch64
|
||||||
|
- armv7
|
||||||
|
- x86_64
|
||||||
|
publish:
|
||||||
|
description: 'Also publish as a pre-release (automatic on master)'
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
# One APK build at a time, and a newer push supersedes an in-flight one — so a
|
||||||
|
# burst of commits to master costs one build, not one per commit. This matters:
|
||||||
|
# the runner has a single slot shared with two other projects.
|
||||||
|
group: build-test-apk
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Incremental state is never reused between CI runs -- pure disk cost.
|
||||||
|
CARGO_INCREMENTAL: 0
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build test APK
|
||||||
|
runs-on: linux/amd64
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
# This runner executes `run:` blocks with `sh` (dash) unless told
|
||||||
|
# otherwise, so bash-only syntax fails with a bare "Bad substitution"
|
||||||
|
# naming a temp file and no line of your workflow. Say bash explicitly.
|
||||||
|
# The short-SHA output below avoids depending on it regardless.
|
||||||
|
shell: bash
|
||||||
|
container:
|
||||||
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
||||||
|
env:
|
||||||
|
ANDROID_HOME: /opt/android-sdk
|
||||||
|
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||||
|
ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# set-version.sh derives a dev version from `git describe --tags`, so
|
||||||
|
# the tags have to be here. A shallow checkout yields 0.0.0.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# One place decides what this run is, so the build, the collect step and
|
||||||
|
# the publish step cannot disagree about it. A push carries no dispatch
|
||||||
|
# inputs at all -- every `github.event.inputs.*` is empty on that event --
|
||||||
|
# so each value needs an explicit default rather than being read raw.
|
||||||
|
- name: Resolve build parameters
|
||||||
|
id: cfg
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
VARIANT="${{ github.event.inputs.variant }}"
|
||||||
|
ABI="${{ github.event.inputs.abi }}"
|
||||||
|
PUBLISH="${{ github.event.inputs.publish }}"
|
||||||
|
BRANCH="${GITHUB_REF#refs/heads/}"
|
||||||
|
|
||||||
|
VARIANT="${VARIANT:-side-by-side-release}"
|
||||||
|
ABI="${ABI:-aarch64}"
|
||||||
|
|
||||||
|
# A push to master always publishes -- that is the whole point of a
|
||||||
|
# rolling `latest`. A dispatch publishes only if asked. Compared
|
||||||
|
# against the string 'true' rather than used as a bare truthiness
|
||||||
|
# test: dispatch inputs arrive as strings, and every non-empty string
|
||||||
|
# is truthy, so `if: inputs.publish` would publish even when the box
|
||||||
|
# was deliberately left unticked.
|
||||||
|
if [ "$GITHUB_EVENT_NAME" = "push" ]; then
|
||||||
|
PUBLISH=true
|
||||||
|
elif [ "$PUBLISH" = "true" ]; then
|
||||||
|
PUBLISH=true
|
||||||
|
else
|
||||||
|
PUBLISH=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Master is the rolling channel and keeps one stable tag, so the
|
||||||
|
# download URL a tester was given keeps working. Anything else gets
|
||||||
|
# its own branch-scoped tag.
|
||||||
|
if [ "$BRANCH" = "master" ]; then
|
||||||
|
TAG="latest"
|
||||||
|
RELEASE_NAME="Latest build (master)"
|
||||||
|
else
|
||||||
|
TAG="test-$(echo "$BRANCH" | tr '/' '-')"
|
||||||
|
RELEASE_NAME="Test build: $BRANCH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stable asset name for the same reason the tag is stable.
|
||||||
|
ASSET="jellytau-${TAG}.apk"
|
||||||
|
|
||||||
|
# Computed once, with `cut` rather than `${GITHUB_SHA::8}`. The
|
||||||
|
# substring form is bash-only and this runner may hand a step to
|
||||||
|
# `sh`; that cost a 51-minute build which produced a perfectly good
|
||||||
|
# APK and then died formatting the summary table.
|
||||||
|
SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-8)
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "variant=$VARIANT"
|
||||||
|
echo "abi=$ABI"
|
||||||
|
echo "publish=$PUBLISH"
|
||||||
|
echo "tag=$TAG"
|
||||||
|
echo "release_name=$RELEASE_NAME"
|
||||||
|
echo "asset=$ASSET"
|
||||||
|
echo "branch=$BRANCH"
|
||||||
|
echo "short_sha=$SHORT_SHA"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
echo "variant=$VARIANT abi=$ABI publish=$PUBLISH tag=$TAG asset=$ASSET"
|
||||||
|
|
||||||
|
- name: Cache Rust dependencies
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
# Registry only -- never src-tauri/target. Same reasoning (and the
|
||||||
|
# same key) as every other job: that directory is ~16 GB and caching
|
||||||
|
# it filled the runner's 74 GB disk. Sharing the key means this
|
||||||
|
# workflow restores what the others saved rather than adding a
|
||||||
|
# fourth copy of the registry.
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry/index
|
||||||
|
~/.cargo/registry/cache
|
||||||
|
~/.cargo/git/db
|
||||||
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-registry-
|
||||||
|
|
||||||
|
- name: Cache Node dependencies
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.bun/install/cache
|
||||||
|
node_modules
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install
|
||||||
|
|
||||||
|
# Before `android init`: it derives the generated project (including the
|
||||||
|
# initial versionCode) from tauri.conf.json.
|
||||||
|
- name: Stamp a dev version
|
||||||
|
run: ./scripts/set-version.sh
|
||||||
|
|
||||||
|
- name: Initialize Android project
|
||||||
|
run: bun run tauri android init
|
||||||
|
|
||||||
|
# Again after init: tauri.properties only exists now, and its
|
||||||
|
# autogenerated versionCode is neither large enough nor monotonic against
|
||||||
|
# the 1000 floor already shipped. On a branch this derives from
|
||||||
|
# `git describe`, so a test APK always sorts above the last release.
|
||||||
|
- name: Pin a monotonic Android versionCode
|
||||||
|
run: ./scripts/set-version.sh
|
||||||
|
|
||||||
|
# Built through the same script used locally, rather than a hand-rolled
|
||||||
|
# gradle/tauri invocation. That is what keeps CI and a developer's machine
|
||||||
|
# producing the same thing -- and the script asserts the applicationId the
|
||||||
|
# APK actually carries, which has silently regressed before.
|
||||||
|
- name: Build APK
|
||||||
|
run: |
|
||||||
|
if [ "${{ steps.cfg.outputs.variant }}" = "side-by-side-release" ]; then
|
||||||
|
./scripts/build-android.sh release --debug --abi "${{ steps.cfg.outputs.abi }}"
|
||||||
|
else
|
||||||
|
./scripts/build-android.sh debug --abi "${{ steps.cfg.outputs.abi }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Collect APK
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
mkdir -p dist/test-apk
|
||||||
|
if [ "${{ steps.cfg.outputs.variant }}" = "side-by-side-release" ]; then
|
||||||
|
PATTERN='*-release.apk'
|
||||||
|
else
|
||||||
|
PATTERN='*-debug.apk'
|
||||||
|
fi
|
||||||
|
APK=$(find src-tauri/gen/android/app/build/outputs/apk -name "$PATTERN" | head -1)
|
||||||
|
if [ -z "$APK" ]; then
|
||||||
|
echo "❌ No APK produced for variant ${{ steps.cfg.outputs.variant }}"
|
||||||
|
find src-tauri/gen/android/app/build/outputs/apk -name '*.apk' || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
OUT="dist/test-apk/${{ steps.cfg.outputs.asset }}"
|
||||||
|
cp "$APK" "$OUT"
|
||||||
|
|
||||||
|
# Report what the thing actually is, not what it was meant to be.
|
||||||
|
APKSIGNER=$(find "$ANDROID_SDK_ROOT/build-tools" -name apksigner | sort -V | tail -1)
|
||||||
|
"$APKSIGNER" verify --print-certs "$OUT" || echo "⚠️ Could not verify signature"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "### 📱 ${{ steps.cfg.outputs.release_name }}"
|
||||||
|
echo ""
|
||||||
|
echo "| | |"
|
||||||
|
echo "|---|---|"
|
||||||
|
echo "| Branch | \`${{ steps.cfg.outputs.branch }}\` |"
|
||||||
|
echo "| Commit | \`${{ steps.cfg.outputs.short_sha }}\` |"
|
||||||
|
echo "| Variant | \`${{ steps.cfg.outputs.variant }}\` |"
|
||||||
|
echo "| ABI | \`${{ steps.cfg.outputs.abi }}\` |"
|
||||||
|
echo "| Size | $(du -h "$OUT" | cut -f1) |"
|
||||||
|
echo "| SHA256 | \`$(sha256sum "$OUT" | cut -d' ' -f1)\` |"
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
ls -lah dist/test-apk/
|
||||||
|
|
||||||
|
# Deliberately NOT tagged `v*`: that pattern triggers build-release.yml,
|
||||||
|
# which would run the whole three-platform release matrix and publish a
|
||||||
|
# real release. `latest` and `test-*` carry no version, so nothing else
|
||||||
|
# reacts to them.
|
||||||
|
#
|
||||||
|
# This also cannot reach existing users by itself. The desktop updater
|
||||||
|
# reads a static latest.json from the `updater` branch, not the release
|
||||||
|
# list, so a pre-release published here is invisible to anyone who does
|
||||||
|
# not have the link -- and the APK installs under a different
|
||||||
|
# applicationId anyway.
|
||||||
|
- name: Publish pre-release
|
||||||
|
if: ${{ steps.cfg.outputs.publish == 'true' }}
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
command -v jq >/dev/null || { echo "❌ jq is required on the runner"; exit 1; }
|
||||||
|
API="${GITHUB_SERVER_URL}/api/v1"
|
||||||
|
REPO="${GITHUB_REPOSITORY}"
|
||||||
|
TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}"
|
||||||
|
TAG="${{ steps.cfg.outputs.tag }}"
|
||||||
|
ASSET="${{ steps.cfg.outputs.asset }}"
|
||||||
|
|
||||||
|
BODY=$(printf '%s\n' \
|
||||||
|
"Automatic build of \`${{ steps.cfg.outputs.branch }}\` at \`${{ steps.cfg.outputs.short_sha }}\` — **not a release**." \
|
||||||
|
"" \
|
||||||
|
"Installs as **JellyTau Debug** (\`com.dtourolle.jellytau.debug\`), alongside a" \
|
||||||
|
"normal install and with its own separate data. It cannot replace or upgrade a" \
|
||||||
|
"real install, and uninstalling it does not touch one." \
|
||||||
|
"" \
|
||||||
|
"R8-minified like a real release, but signed with a debug key — so Android will" \
|
||||||
|
"warn about an unknown source. That is expected." \
|
||||||
|
"" \
|
||||||
|
"Variant: \`${{ steps.cfg.outputs.variant }}\` · ABI: \`${{ steps.cfg.outputs.abi }}\`" \
|
||||||
|
"" \
|
||||||
|
"This release is refreshed on every push; the download link stays the same.")
|
||||||
|
|
||||||
|
PAYLOAD=$(jq -n \
|
||||||
|
--arg tag "$TAG" \
|
||||||
|
--arg name "${{ steps.cfg.outputs.release_name }}" \
|
||||||
|
--arg body "$BODY" \
|
||||||
|
--arg target "$GITHUB_SHA" \
|
||||||
|
'{tag_name:$tag, target_commitish:$target, name:$name, body:$body, draft:false, prerelease:true}')
|
||||||
|
|
||||||
|
HTTP=$(curl -sS -o resp.json -w '%{http_code}' -X POST "$API/repos/$REPO/releases" \
|
||||||
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" -d "$PAYLOAD")
|
||||||
|
|
||||||
|
if [ "$HTTP" = "201" ]; then
|
||||||
|
RELEASE_ID=$(jq -r '.id' resp.json)
|
||||||
|
elif [ "$HTTP" = "409" ]; then
|
||||||
|
# The rolling case: reuse the release, refresh its body to name the
|
||||||
|
# new commit, and clear the old asset so `latest` means latest.
|
||||||
|
RELEASE_ID=$(curl -fsS "$API/repos/$REPO/releases/tags/$TAG" \
|
||||||
|
-H "Authorization: token $TOKEN" | jq -r '.id')
|
||||||
|
echo "ℹ️ Refreshing existing pre-release $TAG (id=$RELEASE_ID)"
|
||||||
|
curl -fsS -X PATCH "$API/repos/$REPO/releases/$RELEASE_ID" \
|
||||||
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d "$PAYLOAD" >/dev/null
|
||||||
|
for id in $(curl -fsS "$API/repos/$REPO/releases/$RELEASE_ID/assets" \
|
||||||
|
-H "Authorization: token $TOKEN" | jq -r '.[].id'); do
|
||||||
|
curl -fsS -X DELETE "$API/repos/$REPO/releases/$RELEASE_ID/assets/$id" \
|
||||||
|
-H "Authorization: token $TOKEN" >/dev/null
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "❌ Failed to create pre-release (HTTP $HTTP):"; cat resp.json; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The tag moves with the branch, so an old tag object would otherwise
|
||||||
|
# keep `latest` pointing at a stale commit.
|
||||||
|
curl -fsS -X POST \
|
||||||
|
"$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$ASSET" \
|
||||||
|
-H "Authorization: token $TOKEN" -F "attachment=@dist/test-apk/$ASSET" >/dev/null
|
||||||
|
|
||||||
|
URL="${GITHUB_SERVER_URL}/${REPO}/releases/download/${TAG}/${ASSET}"
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "**Published:** ${GITHUB_SERVER_URL}/${REPO}/releases/tag/${TAG}"
|
||||||
|
echo ""
|
||||||
|
echo "Direct download (stable link, no account needed):"
|
||||||
|
echo ""
|
||||||
|
echo " $URL"
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "✅ Published $TAG -> $URL"
|
||||||
|
|
||||||
|
- name: Upload APK artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: jellytau-test-apk
|
||||||
|
path: dist/test-apk/
|
||||||
|
retention-days: 7
|
||||||
+168
@@ -9,6 +9,174 @@ 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
|
For how long each fixed defect had been shipping before it was found, see
|
||||||
[docs/defect-windows.md](docs/defect-windows.md).
|
[docs/defect-windows.md](docs/defect-windows.md).
|
||||||
|
|
||||||
|
## v0.12.0
|
||||||
|
|
||||||
|
JellyTau works against Jellyfin 12. Jellyfin 12.0 shipped on 2026-09-08 and
|
||||||
|
turns off, by default, the two ways every earlier JellyTau build identified
|
||||||
|
itself to a server — including on servers that were upgraded rather than freshly
|
||||||
|
installed. An app that was not changed for it stops signing in the day the server
|
||||||
|
updates. This release is changed for it, and still works against 10.11, so the
|
||||||
|
app can be updated first and the server whenever it suits.
|
||||||
|
|
||||||
|
There is no Jellyfin 11. The project dropped the leading `10` from its version
|
||||||
|
scheme: what would have been 10.12.0 shipped as 12.0. Anything that compares
|
||||||
|
Jellyfin version numbers needed to learn that, and this app now has.
|
||||||
|
|
||||||
|
### ✨ Changes
|
||||||
|
|
||||||
|
- **Signing in survives a server upgrade to Jellyfin 12.** The server used to be
|
||||||
|
told who was asking through a header and a URL parameter that 12.0 disables by
|
||||||
|
default — a migration disables them on upgraded servers too, so nothing in an
|
||||||
|
admin's hands changes the outcome. The replacement spellings are accepted by
|
||||||
|
10.11 and 12 alike, so this is one way of identifying the app that works
|
||||||
|
everywhere, not a switch between two. The URL half matters more than it
|
||||||
|
sounds: video and audio are streamed by the device's media player, which cannot
|
||||||
|
send headers at all, so the URL parameter is the only way playback can
|
||||||
|
authenticate. A test now refuses any request built with the old spellings,
|
||||||
|
because the failure is silent right up until a server upgrades.
|
||||||
|
(UR-085 → DR-287)
|
||||||
|
|
||||||
|
- **Browsing a library returns the same things on both server versions.** 12.0
|
||||||
|
changed what a filtered library listing means — asked for the films in a
|
||||||
|
library, 10.11 returned the folder's immediate contents and 12.0 returns
|
||||||
|
everything beneath it, and nothing in the reply says which rule applied. The
|
||||||
|
app now says which it wants, so both servers answer the same way, and the
|
||||||
|
answer is the one it always had. (UR-085 → DR-288)
|
||||||
|
|
||||||
|
- **The app knows what server it is talking to, once, and adapts.** The server
|
||||||
|
version was already fetched at sign-in and then thrown away. It is now
|
||||||
|
resolved into a small set of named capabilities that every version-dependent
|
||||||
|
decision reads from, rather than the version number being compared wherever
|
||||||
|
somebody needed it — which is unreadable by the second occurrence and cannot
|
||||||
|
express a backport. A server newer than this build is treated as the newest
|
||||||
|
one it knows and keeps working; refusing it would make every release expire
|
||||||
|
the moment the server updated. Only a server older than 10.10 is refused, and
|
||||||
|
the sign-in screen says so and names the minimum. (UR-085 → IR-035, DR-280,
|
||||||
|
DR-286)
|
||||||
|
|
||||||
|
- **The cached library re-fetches itself after a server upgrade.** Nothing had
|
||||||
|
recorded which server version wrote the cached catalog, so a server upgraded
|
||||||
|
underneath the app kept serving rows read under the old rules. The generation
|
||||||
|
is now recorded, and a change clears the cache so it fills back under the new
|
||||||
|
one. The first launch of this version records and clears nothing — an
|
||||||
|
existing install is not charged a full re-download to defend against an
|
||||||
|
upgrade that has not happened. (UR-085 → DR-284)
|
||||||
|
|
||||||
|
### 🛠 Development
|
||||||
|
|
||||||
|
- **Every route the app speaks lives in one place.** Fifty-seven inline URL
|
||||||
|
strings across the server adapter became one module of route functions, each
|
||||||
|
taking the resolved capabilities. Both shapes of the item routes Jellyfin has
|
||||||
|
deprecated are built and tested, though nothing selects the second yet — the
|
||||||
|
family still works on 12.0, and 12.0's written policy that unlisted endpoints
|
||||||
|
may go in any major release is why having the alternative ready costs less
|
||||||
|
than needing it. (UR-085 → DR-279, DR-282)
|
||||||
|
|
||||||
|
- **The server adapter is tested against a server.** There was no HTTP mocking
|
||||||
|
in the Rust tree at all: every test of the adapter asserted on a URL string it
|
||||||
|
had built, and none exercised a reply. A fake Jellyfin now answers over real
|
||||||
|
HTTP and reports whichever version a test asks for, so the same assertions run
|
||||||
|
against both generations through the production resolution path. The one
|
||||||
|
file that had tried this before reimplemented the URL builders inside its own
|
||||||
|
mock and asserted against itself — and had never compiled, and had once
|
||||||
|
stayed green while the real code shipped a download endpoint that 404s. It is
|
||||||
|
deleted, and the rule it teaches is written at the top of its replacement.
|
||||||
|
(UR-085 → DR-281)
|
||||||
|
|
||||||
|
- **A Jellyfin URL was being built in the interface layer** — the last one,
|
||||||
|
and, it turned out, unused. Deleted rather than moved. (UR-085 → DR-285)
|
||||||
|
|
||||||
|
### ⚠️ Known limits
|
||||||
|
|
||||||
|
Every cross-version assertion runs against a fake server built from a
|
||||||
|
source-level diff of the two Jellyfin releases, not against a running 12.0. Two
|
||||||
|
things that diff could not settle: whether remote control and casting behave
|
||||||
|
identically, and whether the audio-codec check that forces a transcode on 10.11
|
||||||
|
is still needed on 12 — it is left on, which errs toward an unnecessary
|
||||||
|
transcode rather than silent playback. Both resolve with a real 12.0 server;
|
||||||
|
reports welcome.
|
||||||
|
|
||||||
|
**Upgrading:** install this version *before* upgrading the server, not after.
|
||||||
|
It works against both; an older JellyTau does not work against 12.
|
||||||
|
|
||||||
|
## v0.11.6
|
||||||
|
|
||||||
|
Found by an audit of the stack's most fragile seams rather than by hitting them,
|
||||||
|
so most of these are faults that had not yet been reported — several could only
|
||||||
|
be reached on a bad day, and the worst of them only once.
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **An interrupted update can no longer stop the app from ever opening again.**
|
||||||
|
Changes to the local database were applied one statement at a time with no way
|
||||||
|
to undo a half-finished one. If an update was interrupted partway — a full
|
||||||
|
disk, the phone reclaiming memory, the app being killed mid-launch — the
|
||||||
|
earlier statements stuck while nothing recorded that the change had happened.
|
||||||
|
On the next launch it started again from the beginning, immediately hit the
|
||||||
|
part that was already done, and gave up; and since the app treats a database
|
||||||
|
it cannot prepare as fatal, it stopped opening at all, on every launch, with
|
||||||
|
the only way out being to clear its data and lose downloads and sign-ins. Each
|
||||||
|
change is now all-or-nothing, so an interrupted one leaves no trace and the
|
||||||
|
next launch simply tries again. (UR-002 → DR-012)
|
||||||
|
|
||||||
|
- **The app no longer vanishes without trace when the player hits trouble.**
|
||||||
|
The parts of the Android player that report back into the app — position,
|
||||||
|
state changes, errors, the end of a track — had no protection around them, and
|
||||||
|
a failure inside one killed the whole app instantly: no error, no message, not
|
||||||
|
even a crash report worth sending. One such failure was reachable in ordinary
|
||||||
|
use, on the position report that fires four times a second: under memory
|
||||||
|
pressure the app could fail to build the small worker it needs to send that
|
||||||
|
report, and that alone was enough to take everything down. A dropped position
|
||||||
|
report is now just a dropped position report. (UR-005 → DR-052)
|
||||||
|
|
||||||
|
- **One internal failure no longer disables the whole app until it is restarted.**
|
||||||
|
Every part of the app that reads or writes local data shares a single gate to
|
||||||
|
it. If anything failed while holding that gate, the gate stayed jammed: from
|
||||||
|
then on every library page, download, setting and sign-in returned an error for
|
||||||
|
the rest of the session, and only quitting and reopening cleared it. The gate
|
||||||
|
now recovers instead of jamming. (UR-002 → DR-012)
|
||||||
|
|
||||||
|
- **Browsing offline no longer reports a network error over content already on
|
||||||
|
the device.** A read of local data was given a tenth of a second to answer and
|
||||||
|
otherwise abandoned and treated as "nothing stored". That is easily exceeded
|
||||||
|
on phone storage whenever something else is writing — a sync catching up, a
|
||||||
|
batch of artwork being saved — and offline, where there is no server to fall
|
||||||
|
back to, the result was a network error shown over a library that was sitting
|
||||||
|
on disk. Worse, the abandoned read kept running and kept the storage busy,
|
||||||
|
making the next one slower still. A slow read is now waited for rather than
|
||||||
|
thrown away, and a fast one still answers immediately as before. (UR-002 →
|
||||||
|
DR-013)
|
||||||
|
|
||||||
|
- **A download that arrived empty is no longer presented as ready to play.** If
|
||||||
|
the server answered a download with nothing at all — an error page, a
|
||||||
|
conversion that produced no output — the empty file was moved into place and
|
||||||
|
the item was marked available offline. Opening it then hung: the app's own
|
||||||
|
media server promised one byte of it and sent none, so the player waited
|
||||||
|
forever with nothing on screen to say why. An empty download is now treated as
|
||||||
|
the failure it is, keeping the partial file so it can resume, and a request for
|
||||||
|
an empty file gets an honest refusal instead of a promise. (UR-019, UR-071 →
|
||||||
|
DR-168, DR-137)
|
||||||
|
|
||||||
|
- **Renaming your computer no longer signs you out.** On systems without a
|
||||||
|
password manager, sign-in tokens are kept in a file whose key was rebuilt from
|
||||||
|
the machine's name and the current username each time the app started. Rename
|
||||||
|
the machine, or launch it from somewhere the username is not set, and the key
|
||||||
|
came out different, the file could no longer be read, and the app treated that
|
||||||
|
as never having been signed in — with nothing shown to explain it. The key is
|
||||||
|
now made once, kept, and unaffected by what the machine is called. Existing
|
||||||
|
saved sign-ins are carried over automatically. This file has never been a
|
||||||
|
substitute for a real password manager, and the app now says so plainly rather
|
||||||
|
than implying otherwise. (UR-012 → IR-014)
|
||||||
|
|
||||||
|
- **The player's internal locking is now checked rather than merely careful.**
|
||||||
|
The playback controller coordinates seventeen separate pieces of shared state
|
||||||
|
across the audio engine, the lock screen, timers and every screen in the app.
|
||||||
|
Nothing stopped two of them being taken in opposite orders by different parts
|
||||||
|
of the code, which freezes playback outright with no error anywhere — a fault
|
||||||
|
this part of the app has produced before. The correct order is now written down
|
||||||
|
and enforced automatically, so a future change cannot quietly reintroduce it.
|
||||||
|
No such fault existed; this keeps it that way. (UR-005 → DR-052)
|
||||||
|
|
||||||
## v0.11.5
|
## v0.11.5
|
||||||
|
|
||||||
### 🐛 Fixes
|
### 🐛 Fixes
|
||||||
|
|||||||
+46
-1
@@ -91,6 +91,10 @@ 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-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-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-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-085 | Upgrading the server does not break the app, and the app does not force the upgrade. A server and its clients are updated by different people on different schedules — a family server can sit a major version behind for a year while the phone updates itself weekly — but the app encodes one server generation's routes and quirks unconditionally, as fact rather than as a branch. So the first release that follows the server forward silently abandons everyone who has not moved, and the failure reaches the user as a broken app rather than as a version mismatch. The app instead asks the server what it is, adapts to the answer, keeps working against a server merely newer than the release, and says plainly when it is talking to one it cannot use | 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 |
|
| 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 +140,8 @@ 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-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-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-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 |
|
||||||
|
| IR-035 | Server capability negotiation: one `ServerCapabilities` value is resolved per connection from the version the server already reports at `/System/Info/Public`, and every version-dependent decision — route shape, device-profile override, cache validity — reads a named flag from it. Flags rather than version comparisons, because a `version < N` at the point of use re-derives a domain fact where it is consumed, is unreadable by its second occurrence, and cannot express a backport. Detection itself is free: `connect_to_server` already parses the version before login and the `servers` table already has a column for it; the value is simply discarded today | System | UR-085 | Proposed |
|
||||||
|
|
||||||
> **Where a UR is met by a different mechanism than its IR anticipated.** Several
|
> **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
|
> integration requirements were written when libmpv was expected to be the single
|
||||||
@@ -199,6 +205,7 @@ API endpoints and data contracts required for Jellyfin integration.
|
|||||||
| JA-034 | Read `UserData` (favourite, played, resume position) from item responses | UserData | UR-069 | Done |
|
| JA-034 | Read `UserData` (favourite, played, resume position) from item responses | UserData | UR-069 | Done |
|
||||||
| JA-035 | Mark item played (`POST /Users/{userId}/PlayedItems/{itemId}`) | UserData | UR-025 | Done |
|
| JA-035 | Mark item played (`POST /Users/{userId}/PlayedItems/{itemId}`) | UserData | UR-025 | Done |
|
||||||
| JA-036 | Query next-up episodes excluding in-progress ones (`/Shows/NextUp` with `EnableResumable=false`) | Shows | UR-059 | Done |
|
| JA-036 | Query next-up episodes excluding in-progress ones (`/Shows/NextUp` with `EnableResumable=false`) | Shows | UR-059 | Done |
|
||||||
|
| JA-037 | Read the server version from `/System/Info/Public` and select route shape from it — user-scoped `/Users/{userId}/Items` against `/Items?userId=` and its siblings | System | UR-085 | Proposed |
|
||||||
|
|
||||||
### 2.3 Development Requirements
|
### 2.3 Development Requirements
|
||||||
|
|
||||||
@@ -460,7 +467,29 @@ Internal architecture, components, and application logic.
|
|||||||
| 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-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-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-266 | PiP and the background-audio handoff can no longer be armed at once, and neither can a single stale boolean end the picture. They are alternatives — one keeps the video on screen, the other throws it away — but exclusivity was enforced from one side only: arming the toggle called `setAutoEnterEnabled(false)`, while the PiP *button* stayed ungated and still worked, so pressing it left both live. What then decided between them was `isInPictureInPictureMode`, sampled once inside `MainActivity.onStop()` and passed to `background_action`. That sample is not reliable: there are orderings — the keyguard dismissing the window, the window being stashed, OEM variance in when `onPictureInPictureModeChanged(false)` lands relative to `onStop` — where the activity is stopped with a PiP window still on screen and the flag reads false. Backgrounding then meant "the app is gone" and handed a video the user was watching in the window off to audio-only. Two halves. (a) `enteringPictureInPicture` disarms background audio, because pressing PiP is an unambiguous request to keep the picture; both directions now go through one `BackgroundBehaviour` pair rather than two ad-hoc call sites. (b) `inPictureInPicture` accepts either witness — the native sample or the frontend's own latch over `jellytau-pip-entered`/`jellytau-pip-exited`. The latch cannot report a window that has closed, because both events reach the WebView through the same message queue in dispatch order, so a genuine exit is always known before the background signal that follows it. The decision itself stays in Rust; the frontend only supplies a fact it can establish more reliably than the activity can | Player | UR-040, UR-041 | Done |
|
||||||
|
| DR-267 | `profiles_*` commands expose the accounts already stored in `users` — list, add, remove, and a startup target that decides between resuming the last account and showing the picker. `storage_get_users` and `storage_set_active_user` have existed and gone uncalled since the schema was written; what was missing was never the storage but the decision of who may switch to what, which is domain logic and stays in Rust. Adding an account authenticates against the *current* server and takes no URL, which is how the same-server constraint is enforced rather than by omitting a form field | Auth | UR-082 | Proposed |
|
||||||
|
| DR-268 | A profile's PIN is an Argon2id hash in `user_pins` with the failure count and lockout deadline beside it, both read and written only in Rust. The PIN deliberately does **not** encrypt the access token: wrapping it would leave a locked profile unable to resume its own downloads, drain its own `sync_queue` or poll its own sessions until someone typed the code, which on a device that reboots nightly costs more than it defends against a four-digit secret. The gate is against a member of the household, and the security doc says so rather than implying at-rest protection it does not provide | Auth | UR-083 | Proposed |
|
||||||
|
| DR-269 | A forgotten PIN falls through to the ordinary password login against the same server, after which a new PIN can be set. There is no reset token, no recovery secret and no administrator approval path — the account's own password is already the authority, and inventing a second one would be a weaker credential guarding the same thing | Auth | UR-084 | Proposed |
|
||||||
|
| DR-270 | Switching profiles is its own operation, not a logout. `auth_logout` calls Jellyfin's logout endpoint and invalidates the token server-side, which is exactly the behaviour a switch must not have. The switch runs as a state machine over a `ProfileSession` — stop playback and drop the queue, park the outgoing user's sync queue, stop the poller, destroy the repository, flip `is_active`, rebuild — so the teardown *ordering* can be unit-tested with no player and no server. The queue cannot outlive its owner: a straggler reporting after the flip would attribute one account's viewing to another, which is silent and unrecoverable | Auth | UR-082 | Proposed |
|
||||||
|
| DR-271 | Cache visibility is a byproduct of the write path rather than a maintained index. `save_to_cache` is the single choke point through which every cached item passes and it already holds the `user_id` it fetched for, so it stamps `user_item_visibility` in the same transaction; reads join through it. The stamp cannot disagree with the server because it *is* the record of what the server returned, and there is nothing to reconcile. It does not shrink when permissions tighten — that drift is bounded by re-deriving from `/UserViews` on unlock while online, and is stale-permissive offline by design | Repository | UR-082 | Proposed |
|
||||||
|
| DR-272 | `offline_is_available` answers per user instead of per item. It counted completed `downloads` rows for an `item_id` with no user predicate, so every profile on the device saw every other profile's downloads as its own — the same class of leak as the shared metadata cache, in the one place where the server is not present to filter | Storage | UR-082 | Proposed |
|
||||||
|
| DR-273 | Each profile derives its own `DeviceId` as `uuid5(device_uuid, user_id)` rather than sharing the installation's. Jellyfin identifies a *session* by device, so a shared id makes a family look like one device that keeps changing user — playback history, the Devices dashboard and the remote-control target all collapse together | Auth | UR-082 | Proposed |
|
||||||
|
| DR-274 | Startup shows the picker only when the last-used profile has a PIN, or when more than one profile exists and the setting asks for it; otherwise it resumes exactly as before. The feature is invisible to a single-account install, which is what makes it safe to ship without a migration anyone has to think about | Auth | UR-082 | Proposed |
|
||||||
|
| DR-275 | Idle re-lock separates "the UI is locked" from "who is the active profile", so audio keeps playing and keeps reporting as the account that started it while the screen is locked. Lockscreen transport controls keep working untouched, because nothing on a lockscreen browses or starts new content — the locked UI refuses only what reaches past the current queue. The timer starts when playback stops rather than when the UI goes quiet, and unlocking to a *different* profile stops playback. It lives in Rust beside the player state machine: it needs authoritative playback state, and a frontend timer dies with the WebView on Android | Player | UR-083 | Proposed |
|
||||||
|
| DR-276 | The picker and PIN pad render an opaque `unlock_method` and an `UnlockOutcome` union the backend returns; the frontend never compares a PIN, counts an attempt, or infers that an account without a PIN is a child's. "Child account" is not modelled at all — a child profile is simply one with no PIN — so no role taxonomy is invented on either side of a boundary that has leaked taxonomy before | Frontend | UR-082, UR-083 | Proposed |
|
||||||
|
| DR-277 | A library listing is scoped to that library. The cached-browse query matched a library parent with an `EXISTS` that never referenced the item — it asked only whether a library with the requested id existed — so the clause was true for every cached row on the server. Music, Movies and TV concealed it because their landing pages pass `include_item_types`, which narrowed the result; the generic library page passes none, so opening Books, Photos, Collections or a mixed library served whatever happened to be cached. The stored `library_id` now decides wherever the cache kept one, because that is the server's own answer and the only thing able to scope a library whose type has no mapping or none at all; the `collection_type` ↔ `item_type` taxonomy is the fallback for rows written before it was stored, and a library with neither matches nothing and falls through to the server. The taxonomy itself is now a single macro shared with the downloaded listing, which had the identical defect fixed in isolation (DR-167) while this path kept it | Repository | UR-007 | Done |
|
||||||
|
| DR-278 | Cached items record the library they came from. `save_to_cache` bound `library_id` NULL on every row it wrote, so the only association available was the `collection_type` ↔ `item_type` taxonomy — which cannot distinguish two libraries of the *same* type (a server with "TV" and "Shows" served both the same contents) and says nothing about a library whose type it does not map. The write path is the single choke point every cached row passes through and it already knows the parent being browsed, so it resolves the owning library once per call: the parent itself when it is a library, otherwise the library its parent item was already filed under, which propagates the association down a hierarchy as it is browsed. Synthetic parents such as `favorites` match neither and stay NULL, since they are not a library and span several. Existing rows cannot be repaired locally — the association was never stored — so migration 025 clears `synced_at` to force a re-fetch, the same move MIGRATION_018 made for `is_folder`; the taxonomy fallback stays for one release while caches refill | Repository | UR-007 | Done |
|
||||||
|
| DR-279 | Endpoints live in one route table, not 57 inline `format!` literals with their query strings baked in at the point of use. `repository/endpoints.rs` holds roughly thirty functions, each taking `&ServerCapabilities` and returning a path; `online.rs` keeps the three helpers every request already funnels through (`get_json`, `post_json`, `post_json_response`), so the interception point is 32 call sites rather than 57 literals. Behaviour-preserving on its own and a precondition for everything else: without it, a second route shape is 57 conditionals | Repository | UR-085 | Proposed |
|
||||||
|
| DR-280 | `ServerCapabilities` is resolved once at connect and hung on `OnlineRepository`, with the version → flags mapping in exactly one function and no version comparison anywhere else. `OfflineRepository` has no server and no capabilities; `HybridRepository` delegates. No `MediaRepository` method signature changes, so nothing above `repository/` learns that server generations exist | Repository | UR-085 | Proposed |
|
||||||
|
| DR-281 | The online repository is testable against a response, not a URL string. `src-tauri/` contains no HTTP mocking of any kind — every existing test of the 4,797-line adapter asserts on a constructed URL — so there is currently no mechanism by which "works against both server generations" could be demonstrated. A mock HTTP server plus one recorded fixture set per generation makes the repository suite parameterisable over them. This is the largest item in the version work and is worth doing on its own merits: an adapter that size with no response-level tests is under-covered whatever it talks to | Testing | UR-085 | Proposed |
|
||||||
|
| DR-282 | The legacy user-scoped routes become capability-selected rather than assumed. Roughly twelve sites use `/Users/{uid}/Items`, `/Users/{uid}/Items/Resume`, `/Users/{uid}/Views`, `/Users/{uid}/FavoriteItems/{id}` and `/Users/{uid}/PlayedItems/{id}` — precisely the family upstream has been moving away from in favour of `/Items?userId=`. Whichever release drops them takes the app with it, and the change is wide but mechanical once the route table exists | Repository | UR-085 | Proposed |
|
||||||
|
| DR-283 | The device-profile and `PlaybackInfo` overrides fire only on the server generation they were written for. They are unconditional today and documented as version-specific in the same breath — "the override that exists because Jellyfin 10.11.5 ignores…" — so each is correct for one server and wrong for another with nowhere to say which. Gating them is where the versions differ semantically rather than structurally, which is why it needs per-generation tests and not a compile-time switch | Playback | UR-085 | Proposed |
|
||||||
|
| DR-284 | Cached rows record the server generation that wrote them, and a change invalidates by clearing `synced_at`. The cache is version-blind today: a server upgraded underneath the app keeps serving rows parsed under the previous generation's assumptions, and existing rows cannot be repaired locally because the association was never stored. This is the move MIGRATION_018 and migration 025 already make, for the same reason | Storage | UR-085 | Proposed |
|
||||||
|
| DR-285 | Image URLs are built in Rust. `imageCache.ts` constructs `${serverUrl}/Items/${itemId}/Images/${imageType}` in the frontend — a Jellyfin route, therefore something that changes when Jellyfin's API changes, which is the project's own litmus test for domain logic. It is the last such leak, `check:boundary` does not catch it (the tripwire flags item-type array literals, not route strings), and this is the feature that turns it from misplaced into actively wrong | Frontend | UR-012, UR-085 | Proposed |
|
||||||
|
| DR-286 | An unrecognised server version resolves forward to the newest known capability set and is recorded, rather than rejected: a server merely newer than the release should keep working. Rejection is reserved for a version below the supported floor, where failure is certain rather than likely, and it crosses the IPC boundary as an opaque state — the frontend renders it and never receives a version number to compare, for the same reason it never receives an item-type list | Repository | UR-085 | 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 |
|
| 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 |
|
||||||
|
| DR-287 | Authentication uses only the spellings Jellyfin 12.0 leaves enabled. 12.0 disables `X-Emby-Authorization`, `X-Emby-Token`, `X-MediaBrowser-Token`, the `Emby` scheme and the `api_key` **query parameter** by default, and a migration (`DisableLegacyAuthorization`) turns them off on upgraded servers too — so a client using them stops working against an upgraded server rather than degrading. This is not a version branch: `Authorization` with the `MediaBrowser` scheme, and `ApiKey` as a query parameter, are ungated on *both* generations, and the header value this app already built was always the correct one. So the fix is a rename at 21 header sites and 28 query sites, not a capability flag. The query-parameter spelling is load-bearing rather than cosmetic: stream URLs are handed to mpv, ExoPlayer and the webview's `<video>`, none of which can set a header, so `ApiKey` is the only way a player authenticates at all. A structural test refuses any deprecated spelling reaching a request builder, because the failure is silent until a server upgrades | Security | UR-085 | Proposed |
|
||||||
|
| DR-288 | A type-filtered listing states `Recursive` explicitly. Jellyfin 12.0 defaults it to true when the parent is a library folder and `IncludeItemTypes` is set, where 10.11 returned immediate children — the identical request, a different result set, with nothing in the response to say which rule applied. Sending the value the client actually wants makes both generations agree, and the value sent is the one that shipped rather than the new server-side default, so this is a compatibility fix and not a silent behaviour change | Repository | UR-085 | Proposed |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -476,7 +505,7 @@ Internal architecture, components, and application logic.
|
|||||||
| 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-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-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-006 | IR-005, IR-006, IR-007, IR-008 | DR-200, DR-201 |
|
||||||
| UR-007 | IR-010 | DR-007, DR-008, DR-016, DR-257, DR-262 |
|
| UR-007 | IR-010 | DR-007, DR-008, DR-016, DR-257, DR-262, DR-277, DR-278 |
|
||||||
| UR-008 | IR-010 | DR-007, DR-011 |
|
| UR-008 | IR-010 | DR-007, DR-011 |
|
||||||
| UR-009 | IR-009, IR-010, IR-011 | - |
|
| UR-009 | IR-009, IR-010, IR-011 | - |
|
||||||
| UR-010 | IR-012, IR-021 | DR-037, DR-059 |
|
| UR-010 | IR-012, IR-021 | DR-037, DR-059 |
|
||||||
@@ -549,6 +578,10 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-078 | - | DR-218 |
|
| UR-078 | - | DR-218 |
|
||||||
| UR-079 | - | DR-225, DR-226, DR-227, DR-228, DR-229, DR-230 |
|
| 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-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 |
|
||||||
|
| UR-085 | IR-035 | DR-279, DR-280, DR-281, DR-282, DR-283, DR-284, DR-285, DR-286, DR-287, DR-288 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -799,6 +832,10 @@ Internal architecture, components, and application logic.
|
|||||||
|
|
||||||
| 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-245 | A `timeupdate` is applied while the video is playing — the case that froze the position behind a PiP window — and still yields to an in-flight seek, a seek-bar drag, and an element with no current data | DR-265 | Done |
|
||||||
| UT-246 | Opening a PiP window disarms background audio, and a background signal arriving with the native PiP flag false is still treated as PiP while the frontend's latch says the window is open — without resurrecting one it has already seen close | DR-266 | Done |
|
| UT-246 | Opening a PiP window disarms background audio, and a background signal arriving with the native PiP flag false is still treated as PiP while the frontend's latch says the window is open — without resurrecting one it has already seen close | DR-266 | Done |
|
||||||
|
| UT-247 | A library whose `collection_type` has no mapping — Books, Photos, a mixed library — does not return the server's films, albums and shows from cache | DR-277 | Done |
|
||||||
|
| UT-248 | Narrowing the library clause does not starve the libraries that do have landing pages: music, movies and TV each still list their own media and none of the others | DR-277 | Done |
|
||||||
|
| UT-249 | Opening an individual collection still lists its own children: a BoxSet's members are matched by the stored `parent_id`, not by the library clause, so narrowing that clause did not empty collections | DR-277 | Done |
|
||||||
|
| UT-250 | Two libraries of the same collection type are not interchangeable: seeded through the real cache write path, a "TV" and a "Shows" library each list their own series and not the other's | DR-278 | Done |
|
||||||
### Integration Tests
|
### Integration Tests
|
||||||
|
|
||||||
| Test ID | Test Description | Traces To | Status |
|
| Test ID | Test Description | Traces To | Status |
|
||||||
@@ -819,6 +856,14 @@ Internal architecture, components, and application logic.
|
|||||||
| IT-016 | Offline library listing end-to-end: with the server unreachable, a library page lists only downloaded media with the toggle off, and additionally reveals greyed-out cached catalog entries with the toggle on | UR-052, DR-078, DR-079, DR-080 | Done |
|
| IT-016 | Offline library listing end-to-end: with the server unreachable, a library page lists only downloaded media with the toggle off, and additionally reveals greyed-out cached catalog entries with the toggle on | UR-052, DR-078, DR-079, DR-080 | Done |
|
||||||
| IT-017 | A download queued from a greyed-out offline catalog entry persists and is resolved and started on reconnect | UR-052, UR-011 | Done |
|
| IT-017 | A download queued from a greyed-out offline catalog entry persists and is resolved and started on reconnect | UR-052, UR-011 | Done |
|
||||||
| IT-018 | The conformance cases run against ExoPlayer on a device: opening from the beginning and at a position, a seek issued while still preparing, a seek after open, pause and play observable, stop silent and idempotent, and a load cancelled by stop never playing. The fixture is a silent WAV synthesised at setup, so the repo carries no media and the duration is exact | DR-247 | Done |
|
| IT-018 | The conformance cases run against ExoPlayer on a device: opening from the beginning and at a position, a seek issued while still preparing, a seek after open, pause and play observable, stop silent and idempotent, and a load cancelled by stop never playing. The fixture is a silent WAV synthesised at setup, so the repo carries no media and the duration is exact | DR-247 | Done |
|
||||||
|
| IT-019 | Every request carries `Authorization: MediaBrowser …` and no `X-Emby-Authorization`, asserted against the header a real HTTP server received, on both the 10.11.x and 12.x generations | UR-085, DR-287 | Done |
|
||||||
|
| IT-020 | A listing parses into domain items on both generations, against a real HTTP response rather than a constructed URL | UR-085, DR-281 | Done |
|
||||||
|
| IT-021 | A type-filtered listing puts `Recursive` on the wire, so 10.11 and 12.0 cannot disagree about the result set | UR-085, DR-288 | Done |
|
||||||
|
| IT-022 | The library listing resolves and parses on both generations, confirming the user-scoped route family still serves 12.0 | UR-085, DR-282 | Done |
|
||||||
|
| IT-023 | Flipping `user_scoped_item_routes` actually changes the wire request to `/Items?userId=` and the response still parses — so the alternative shape is exercised rather than being untested code awaiting a switch | UR-085, DR-282 | Done |
|
||||||
|
| IT-024 | A favourites query sends `Filters=IsFavorite` and omits the type filter under `All` scope, on both generations | UR-067, UR-085, DR-281 | Done |
|
||||||
|
| IT-025 | A player-facing stream URL carries `ApiKey=` and never `api_key=`, on both generations — the only way mpv/ExoPlayer/`<video>` can authenticate, since none can set a header | UR-004, UR-085, DR-287 | Done |
|
||||||
|
| IT-026 | Capabilities are resolved from the version the fake server actually reported, not from a value poked in by the test — which is what makes the other cross-generation assertions meaningful | UR-085, DR-280 | Done |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -27,15 +27,18 @@ know how something *works*, read
|
|||||||
| Design authority | No code of its own — it records a decision later specs act on. |
|
| Design authority | No code of its own — it records a decision later specs act on. |
|
||||||
|
|
||||||
**Next free requirement ids** (always re-check
|
**Next free requirement ids** (always re-check
|
||||||
[requirements.md](../requirements.md) before allocating): **UR-079**,
|
[requirements.md](../requirements.md) before allocating): **UR-086**,
|
||||||
**IR-033**, **DR-232**. Three specs below suggested ids that have since been
|
**IR-036**, **JA-038**, **DR-289**. Three specs below suggested ids that have
|
||||||
taken by other work; each carries a ⚠️ note at the top.
|
since been taken by other work; each carries a ⚠️ note at the top — this line
|
||||||
|
was itself stale by five, two and forty-seven until 2026-09-08, which is why the
|
||||||
|
re-check is not optional.
|
||||||
|
|
||||||
## Partially implemented
|
## Partially implemented
|
||||||
|
|
||||||
| Spec | What landed | What is left |
|
| Spec | What landed | What is left |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [frontend-domain-model.md](frontend-domain-model.md) | Catalog surface: `MediaKind`, `from_jellyfin` isolated, ticks → ms | `primaryImageTag` → `imageId` (~30 sites); player/session/reporting tick math; `stream.type` |
|
| [frontend-domain-model.md](frontend-domain-model.md) | Catalog surface: `MediaKind`, `from_jellyfin` isolated, ticks → ms | `primaryImageTag` → `imageId` (~30 sites); player/session/reporting tick math; `stream.type` |
|
||||||
|
| [jellyfin-server-version-compatibility.md](jellyfin-server-version-compatibility.md) | Route table, `ServerCapabilities`, the auth-spelling fix (the one thing 12.0 actually breaks), explicit `Recursive`, cache generation stamping, the frontend route leak, the unsupported-server state, and an HTTP-level harness that runs the repository against both generations | DR-283: two resolved flags are not consumed yet, and `honours_directplay_audio_codec` is unestablished for 12.x — both need a running 12.x server. Nothing has been tested against a real server of either generation |
|
||||||
| [libmpv2-migration.md](libmpv2-migration.md) | `LICENSE` | The `libmpv` → `libmpv2` crate swap |
|
| [libmpv2-migration.md](libmpv2-migration.md) | `LICENSE` | The `libmpv` → `libmpv2` crate swap |
|
||||||
| [read-through-media-cache.md](read-through-media-cache.md) | DR-126…128, DR-133…138 — cache entries *are* download rows; local playback of downloads | DR-122/124/125 — the read-through capture. DR-121 shipped as backend-owned stream selection and left this spec |
|
| [read-through-media-cache.md](read-through-media-cache.md) | DR-126…128, DR-133…138 — cache entries *are* download rows; local playback of downloads | DR-122/124/125 — the read-through capture. DR-121 shipped as backend-owned stream selection and left this spec |
|
||||||
| [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md) | Stage 1: `SearchScope` owned by Rust (DR-063…067) | Stage 2: result-side grouping (`GROUP_ITEM_TYPES` still in `searchScope.ts`) |
|
| [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md) | Stage 1: `SearchScope` owned by Rust (DR-063…067) | Stage 2: result-side grouping (`GROUP_ITEM_TYPES` still in `searchScope.ts`) |
|
||||||
|
|||||||
@@ -0,0 +1,732 @@
|
|||||||
|
<!--
|
||||||
|
Companion to jellyfin-server-version-compatibility.md — the evidence base for
|
||||||
|
every decision in it. Kept in the repo because the *reasoning* is what a future
|
||||||
|
change needs: which differences were verified, which were looked for and could
|
||||||
|
NOT be established, and which URL each claim came from.
|
||||||
|
|
||||||
|
Delete this alongside the spec when the last of it ships and the design is
|
||||||
|
folded into docs/architecture/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
# Jellyfin server API delta: 10.11.x → next major
|
||||||
|
|
||||||
|
Research date: **2026-09-08**. All claims verified against live sources; no claim below is
|
||||||
|
from model memory. Method: GitHub Releases/Tags API, the official release blog, the published
|
||||||
|
OpenAPI spec, and a **byte-level diff of the actual C# source trees** at tags `v10.11.5` and
|
||||||
|
`v12.0` (downloaded from `codeload.github.com`, extracted locally).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 1 — Version reality check
|
||||||
|
|
||||||
|
### 🔴 Jellyfin 11.0 does not exist and never did.
|
||||||
|
|
||||||
|
The complete tag list of `jellyfin/jellyfin` contains **zero** `v11.*` tags. The project went
|
||||||
|
directly from the `10.11.x` branch to `12.0`.
|
||||||
|
|
||||||
|
Source: `https://api.github.com/repos/jellyfin/jellyfin/tags` (all 8 pages; 116 tags total).
|
||||||
|
Major-version histogram: `v10` × 107, `v12` × 8, `v3` × 1. `11.x tags: []`.
|
||||||
|
|
||||||
|
### What actually exists today (2026-09-08)
|
||||||
|
|
||||||
|
| Version | Status | Published | Source |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **12.0** | **Current stable / `releases/latest`** | **2026-09-08T01:38:39Z** (today) | `https://api.github.com/repos/jellyfin/jellyfin/releases/latest` |
|
||||||
|
| 12.0-rc1 … rc7 | prereleases | 2026-06 → 2026-08-31 | `https://api.github.com/repos/jellyfin/jellyfin/releases` |
|
||||||
|
| 10.11.11 | last release on the 10.11 branch | 2026-06-06T16:18:54Z | `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v10.11.11` |
|
||||||
|
| 10.11.5 | **what JellyTau targets** | 2025-12-15 (file mtime in tag tarball) | `https://codeload.github.com/jellyfin/jellyfin/tar.gz/refs/tags/v10.11.5` |
|
||||||
|
| 10.11.0 | 10.11 branch opened | 2025-10-20 | `https://jellyfin.org/posts/jellyfin-release-10.11.0` |
|
||||||
|
|
||||||
|
**v12.0 was released roughly 18 hours before this research was performed.** Treat "12.0 in the
|
||||||
|
wild" as approximately zero installs today, rising over the coming months.
|
||||||
|
|
||||||
|
### Why the number jumped 10.11 → 12.0
|
||||||
|
|
||||||
|
Official rationale, quoted from the release blog:
|
||||||
|
|
||||||
|
> "The most visible change in this release is the one in its name: we are dropping the major
|
||||||
|
> version '10' from our naming scheme. What would have been 10.12.0 is simply 12.0, and the
|
||||||
|
> server reports its version as `12.0.0`. 10.11.x was the last release branch to use the old
|
||||||
|
> scheme. […] Jumping to 11.0 would still look like a minor increment […]"
|
||||||
|
|
||||||
|
> "**If you maintain anything that parses Jellyfin version strings** — a client, a monitoring
|
||||||
|
> check, a deployment script, a container tag pin — **this is the item to look at before
|
||||||
|
> upgrading.**"
|
||||||
|
|
||||||
|
Source: `https://jellyfin.org/posts/jellyfin-release-12.0` (dated September 7, 2026)
|
||||||
|
|
||||||
|
So: `12.0` *is* `10.12` under the old scheme. It is one release-branch step from 10.11, not two.
|
||||||
|
**"Two server generations from one build" means 10.11.x and 12.x.** There is no third thing.
|
||||||
|
|
||||||
|
⚠️ Direct consequence for JellyTau: `/System/Info/Public` returns `Version: "12.0.0"` on the new
|
||||||
|
generation and `"10.11.5"` on the old. Any version comparison must not assume a leading `10.`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 2 — Confirmed changes
|
||||||
|
|
||||||
|
### 2.1 Routes: the legacy user-scoped family SURVIVES intact
|
||||||
|
|
||||||
|
**The `/Users/{userId}/…` route family that JellyTau depends on in ~17 call sites is NOT removed
|
||||||
|
in 12.0.** Every route the task listed still exists and still functions.
|
||||||
|
|
||||||
|
Verified by diffing every `[HttpGet|Post|Delete|Put|Patch|Head]` attribute across
|
||||||
|
`Jellyfin.Api/Controllers/` in both tags (369 routes in 10.11.5, 364 in 12.0).
|
||||||
|
|
||||||
|
**Complete list of routes removed in 12.0 — all six:**
|
||||||
|
|
||||||
|
| Route | Handler |
|
||||||
|
|---|---|
|
||||||
|
| `POST /Users/{userId}/EasyPassword` | `UpdateUserEasyPassword` |
|
||||||
|
| `GET /Items/{itemId}/CriticReviews` | `GetCriticReviews` |
|
||||||
|
| `GET /Environment/NetworkShares` | `GetNetworkShares` |
|
||||||
|
| `POST /System/MediaEncoder/Path` | `UpdateMediaEncoderPath` |
|
||||||
|
| `GET /LiveTv/Recordings/Groups/{groupId}` | `GetRecordingGroup` |
|
||||||
|
| `GET /QuickConnect/Initiate` | `InitiateQuickConnectLegacy` |
|
||||||
|
|
||||||
|
**Complete list of routes added in 12.0 — one:** `GET /Items/{itemId}/Collections`
|
||||||
|
(`GetItemCollections`).
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- Route diff computed from `https://codeload.github.com/jellyfin/jellyfin/tar.gz/refs/tags/v10.11.5`
|
||||||
|
and `.../v12.0`, directory `Jellyfin.Api/Controllers/`.
|
||||||
|
- Corroborated verbatim by the release notes: "Removed obsolete API routes: `POST
|
||||||
|
/Users/{userId}/EasyPassword` (the EasyPassword feature is gone), `GET
|
||||||
|
/Items/{itemId}/CriticReviews`, `GET /Environment/NetworkShares`, `POST
|
||||||
|
/System/MediaEncoder/Path`, `GET /LiveTv/Recordings/Groups/{groupId}`, and `GET
|
||||||
|
/QuickConnect/Initiate`" — `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v12.0`
|
||||||
|
|
||||||
|
**Confirmed present and functional in v12.0** (`Jellyfin.Api/Controllers/`, tag `v12.0`):
|
||||||
|
|
||||||
|
| Route | File:line in v12.0 |
|
||||||
|
|---|---|
|
||||||
|
| `GET /Users/{userId}/Items` | `ItemsController.cs:721` |
|
||||||
|
| `GET /Users/{userId}/Items/Resume` | `ItemsController.cs:1027` |
|
||||||
|
| `GET /Users/{userId}/Items/Latest` | `UserLibraryController.cs:619` |
|
||||||
|
| `GET /Users/{userId}/Views` | `UserViewsController.cs:107` |
|
||||||
|
| `GET /Users/{userId}/Items/{itemId}` | `UserLibraryController.cs:117` |
|
||||||
|
| `POST /Users/{userId}/FavoriteItems/{itemId}` | `UserLibraryController.cs:252` |
|
||||||
|
| `DELETE /Users/{userId}/FavoriteItems/{itemId}` | `UserLibraryController.cs:300` |
|
||||||
|
| `POST /Users/{userId}/PlayedItems/{itemId}` | `PlaystateController.cs:120` |
|
||||||
|
| `DELETE /Users/{userId}/PlayedItems/{itemId}` | `PlaystateController.cs:185` |
|
||||||
|
|
||||||
|
### 2.2 …but the whole family was ALREADY deprecated in 10.11.5, and 12.0 hardens the policy
|
||||||
|
|
||||||
|
This is **not a new deprecation**. Every one of those methods already carried
|
||||||
|
`[Obsolete("Kept for backwards compatibility")]` **and** `[ApiExplorerSettings(IgnoreApi = true)]`
|
||||||
|
in 10.11.5, at the same positions. Nothing changed about their status between the two versions.
|
||||||
|
|
||||||
|
Confirmed: the 12.0 OpenAPI spec contains only these `/Users` paths — `/Users`,
|
||||||
|
`/Users/AuthenticateByName`, `/Users/AuthenticateWithQuickConnect`, `/Users/Configuration`,
|
||||||
|
`/Users/ForgotPassword`, `/Users/ForgotPassword/Pin`, `/Users/Me`, `/Users/New`,
|
||||||
|
`/Users/Password`, `/Users/Public`, `/Users/{userId}`, `/Users/{userId}/Policy`.
|
||||||
|
**None of the item/view/favorite/played routes appear.**
|
||||||
|
|
||||||
|
Source: `https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json`
|
||||||
|
(`info.version` = `"12.0.0"`, `x-jellyfin-version` = `"12.0.0"`, 294 paths).
|
||||||
|
|
||||||
|
What *is* new in 12.0 is the written removal policy:
|
||||||
|
|
||||||
|
> "If an endpoint isn't listed in the OpenAPI specification it should not be used by clients.
|
||||||
|
> There are certain endpoints that are still exposed for legacy reasons despite being excluded
|
||||||
|
> from the OpenAPI spec. **These can be removed in any major release without warning.**"
|
||||||
|
> "As a general rule, any deprecations will be marked as such for an entire (major) release cycle
|
||||||
|
> before the deprecated endpoint or parameter is liable for removal."
|
||||||
|
|
||||||
|
Source: `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v12.0`
|
||||||
|
|
||||||
|
**Assessment:** the user-scoped family needs no migration to run on 12.0, but it is now formally
|
||||||
|
removable without notice in 13.0. The replacements (`/Items?userId=`, `/UserViews?userId=`,
|
||||||
|
`/UserFavoriteItems/{itemId}`, `/UserPlayedItems/{itemId}`) **already exist in 10.11.5**, so
|
||||||
|
migrating is a one-generation-compatible change, not a branch.
|
||||||
|
|
||||||
|
Verified: `GET /Items` accepts `[FromQuery] Guid? userId` in v12.0
|
||||||
|
(`ItemsController.cs:171-174`), and non-user-scoped twins exist in *both* trees
|
||||||
|
(`UserLibraryController.cs`: `UserFavoriteItems/{itemId}`, `UserItems/{itemId}/Rating`).
|
||||||
|
|
||||||
|
### 2.3 🔴 AUTHENTICATION — the one genuinely breaking change for JellyTau
|
||||||
|
|
||||||
|
**`X-Emby-Authorization` is disabled by default in 12.0, including on upgraded servers.
|
||||||
|
`api_key` as a query parameter is disabled by default in 12.0.**
|
||||||
|
|
||||||
|
The authoritative accepted/deprecated table, from the Jellyfin core team's canonical
|
||||||
|
client-developer gist (last updated 2026-09-08):
|
||||||
|
|
||||||
|
| Type | Name | Method | Deprecated |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Header | `Authorization` | Schema | **No** |
|
||||||
|
| Query | `ApiKey` | Token only | **No**, but discouraged |
|
||||||
|
| Query | `api_key` | Token only | **yes** |
|
||||||
|
| Header | `X-Emby-Token` | Token only | **yes** |
|
||||||
|
| Header | `X-MediaBrowser-Token` | Token only | **yes** |
|
||||||
|
| Header | `X-Emby-Authorization` | Schema | **yes** |
|
||||||
|
|
||||||
|
Source: `https://gist.github.com/nielsvanvelzen/ea047d9028f676185832e51ffaf12a6f`
|
||||||
|
(referenced from PR #13306 and from the 12.0 release notes)
|
||||||
|
|
||||||
|
**Verified in source.** `Jellyfin.Server.Implementations/Security/AuthorizationContext.cs` is
|
||||||
|
**byte-identical between v10.11.5 and v12.0** except one whitespace change
|
||||||
|
(`authorizationHeader[start.. i]` → `[start..i]`). The gating logic in **both** versions:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// always read, no gate:
|
||||||
|
var auth = httpReq.Headers[HeaderNames.Authorization];
|
||||||
|
if (_configurationManager.Configuration.EnableLegacyAuthorization && string.IsNullOrEmpty(auth))
|
||||||
|
{
|
||||||
|
auth = httpReq.Headers["X-Emby-Authorization"];
|
||||||
|
}
|
||||||
|
...
|
||||||
|
var validName = name.Equals("MediaBrowser", StringComparison.OrdinalIgnoreCase); // always OK
|
||||||
|
validName = validName || (…EnableLegacyAuthorization && name.Equals("Emby", …)); // gated
|
||||||
|
...
|
||||||
|
if (…EnableLegacyAuthorization && string.IsNullOrEmpty(token)) { token = headers["X-Emby-Token"]; }
|
||||||
|
if (…EnableLegacyAuthorization && string.IsNullOrEmpty(token)) { token = headers["X-MediaBrowser-Token"]; }
|
||||||
|
if (string.IsNullOrEmpty(token)) { token = queryString["ApiKey"]; } // NOT gated
|
||||||
|
if (…EnableLegacyAuthorization && string.IsNullOrEmpty(token)) { token = queryString["api_key"]; } // gated
|
||||||
|
```
|
||||||
|
|
||||||
|
Source: `https://raw.githubusercontent.com/jellyfin/jellyfin/v12.0/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs`
|
||||||
|
(and the `v10.11.5` path of the same file)
|
||||||
|
|
||||||
|
**The only difference between the two versions is the default of the gate:**
|
||||||
|
|
||||||
|
- `v10.11.5` — `MediaBrowser.Model/Configuration/ServerConfiguration.cs:290`:
|
||||||
|
`public bool EnableLegacyAuthorization { get; set; } = true;`
|
||||||
|
- `v12.0` — same file, same line: `public bool EnableLegacyAuthorization { get; set; }`
|
||||||
|
(no initializer → C# default `false`)
|
||||||
|
|
||||||
|
Source: `https://raw.githubusercontent.com/jellyfin/jellyfin/v10.11.5/MediaBrowser.Model/Configuration/ServerConfiguration.cs`
|
||||||
|
and `.../v12.0/...`
|
||||||
|
|
||||||
|
**Existing installs are flipped too**, by a migration that runs on first boot:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[JellyfinMigration("2026-05-31T16:00:00", nameof(DisableLegacyAuthorization), …)]
|
||||||
|
public class DisableLegacyAuthorization : IAsyncMigrationRoutine
|
||||||
|
{
|
||||||
|
public Task PerformAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_serverConfigurationManager.Configuration.EnableLegacyAuthorization = false;
|
||||||
|
_serverConfigurationManager.SaveConfiguration();
|
||||||
|
```
|
||||||
|
|
||||||
|
Source: `tree/jellyfin-12.0/Jellyfin.Server/Migrations/Routines/20260531160000_DisableLegacyAuthorization.cs`
|
||||||
|
(from `https://codeload.github.com/jellyfin/jellyfin/tar.gz/refs/tags/v12.0`)
|
||||||
|
|
||||||
|
Release-note wording: "Legacy authorization is now disabled by default, and a migration disables
|
||||||
|
it on existing installs as well."
|
||||||
|
Source: `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v12.0`
|
||||||
|
|
||||||
|
Blog wording: "the deprecated way of signing in is now disabled, including on existing servers."
|
||||||
|
Source: `https://jellyfin.org/posts/jellyfin-release-12.0`
|
||||||
|
|
||||||
|
Change history (all merged):
|
||||||
|
- PR #13306 "Add option to disable deprecated legacy authorization options", merged
|
||||||
|
2025-01-11, shipped in 10.11 with default `true`. Body: *"The only method we'll allow is the
|
||||||
|
`Authorization` header with `MediaBrowser` scheme and the `ApiKey` query parameter. The other
|
||||||
|
headers (`X-Emby-Authorization`, `X-Emby-Token`, `X-MediaBrowser-Token`), query parameter
|
||||||
|
(`api_key`) and authorization scheme (`Emby`) are all deprecated."*
|
||||||
|
`https://api.github.com/repos/jellyfin/jellyfin/pulls/13306`
|
||||||
|
- PR #15559 "Disable legacy authorization methods by default", merged 2025-11-27. Body:
|
||||||
|
*"We'll remove this configuration option (and the authorization methods) in a future release,
|
||||||
|
likely 10.13."* `https://api.github.com/repos/jellyfin/jellyfin/pulls/15559`
|
||||||
|
- PR #16754 "Keep legacy authorization enabled" (temporary revert), merged 2026-05-05.
|
||||||
|
`https://api.github.com/repos/jellyfin/jellyfin/pulls/16754`
|
||||||
|
- PR #16992 "Re-disable legacy authorization methods by default", merged 2026-06-01 — the
|
||||||
|
state that shipped. `https://api.github.com/repos/jellyfin/jellyfin/pulls/16992`
|
||||||
|
|
||||||
|
#### 🟢 The critical good news: query-param auth for media players is SAFE
|
||||||
|
|
||||||
|
`ApiKey` (capital A, capital K, no underscore) as a **query parameter** is **not** deprecated and
|
||||||
|
**not** gated in either version. The server itself generates it — identically in both trees:
|
||||||
|
|
||||||
|
- `v10.11.5` `MediaBrowser.Model/Dlna/StreamInfo.cs:1042` → `sb.Append("&ApiKey=");`
|
||||||
|
- `v12.0` `MediaBrowser.Model/Dlna/StreamInfo.cs:1034` → `sb.Append("&ApiKey=");`
|
||||||
|
- `v12.0` `StreamInfo.cs:1279-1280` → `// Use "?ApiKey=" as seen in HEAD and other parts of the code`
|
||||||
|
|
||||||
|
So the load-bearing requirement — handing stream URLs to mpv / ExoPlayer / HTML5 `<video>`, which
|
||||||
|
cannot set headers — **remains satisfied on both generations by one code path**, provided the
|
||||||
|
parameter is spelled `ApiKey` rather than `api_key`.
|
||||||
|
|
||||||
|
#### 🔴 JellyTau uses the disabled spellings today
|
||||||
|
|
||||||
|
Grep of `/home/dtourolle/Development/JellyTau/src-tauri/src`:
|
||||||
|
|
||||||
|
- **21 occurrences of `.header("X-Emby-Authorization", …)`** across
|
||||||
|
`auth/mod.rs` (3), `jellyfin/client.rs` (5), `repository/online.rs` (13).
|
||||||
|
- **28 non-test occurrences of `api_key`**, including every stream URL:
|
||||||
|
`repository/online.rs:1046, 2314` (`/Videos/{}/stream?…&api_key={}`),
|
||||||
|
`online.rs:2338` (`/Audio/{}/stream?…&api_key={}`),
|
||||||
|
`online.rs:2464` (`/Videos/{}/master.m3u8?api_key={}&…`),
|
||||||
|
`online.rs:633, 727, 2626`, plus `player/stream_end.rs`, `player/mod.rs`,
|
||||||
|
`jellyfin/http_client.rs`, `repository/device_profile.rs`, `utils/diagnostics.rs`.
|
||||||
|
|
||||||
|
The header **value** JellyTau already builds is correct — `jellyfin/client.rs:60` emits
|
||||||
|
`MediaBrowser Client="…", Version="…", Device="…", DeviceId="…", Token="…"`, which is exactly the
|
||||||
|
`MediaBrowser` scheme the non-deprecated `Authorization` header expects.
|
||||||
|
|
||||||
|
**Therefore the fix is a rename, not a branch:**
|
||||||
|
- `X-Emby-Authorization` → `Authorization` (value unchanged)
|
||||||
|
- `api_key=` → `ApiKey=`
|
||||||
|
|
||||||
|
Both work on 10.11.5 **and** 12.0. **No capability flag is needed for authentication.**
|
||||||
|
|
||||||
|
### 2.4 `POST /Users/AuthenticateByName` — unchanged
|
||||||
|
|
||||||
|
Request DTO `Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs` and response
|
||||||
|
`MediaBrowser.Controller/Authentication/AuthenticationResult.cs` are **byte-identical** between
|
||||||
|
v10.11.5 and v12.0 (`diff` exit 0, no output). The controller method differs only by an added
|
||||||
|
`[Tags("Authentication")]` OpenAPI annotation.
|
||||||
|
|
||||||
|
Note the endpoint still reads the auth context from the request, so the client-identifying
|
||||||
|
`Authorization: MediaBrowser Client=…, DeviceId=…` header must be present on the login call too.
|
||||||
|
|
||||||
|
`UserDto` (returned inside `AuthenticationResult`) has three fields whose **type widened to
|
||||||
|
nullable**, all annotated obsolete:
|
||||||
|
`HasPassword` `bool` → `bool? = true` `[Obsolete("This information is no longer provided")]`;
|
||||||
|
`HasConfiguredPassword` `bool` → `bool? = true` `[Obsolete("This is always true")]`;
|
||||||
|
`HasConfiguredEasyPassword` `bool` → `bool? = false`.
|
||||||
|
Source: `MediaBrowser.Model/Dto/UserDto.cs` diff between the two tags; corroborated by release
|
||||||
|
notes "`UserDto.HasPassword` is marked obsolete and no longer provides useful information".
|
||||||
|
|
||||||
|
### 2.5 `/System/Info/Public` — unchanged endpoint, changed version string
|
||||||
|
|
||||||
|
`MediaBrowser.Model/System/PublicSystemInfo.cs` is **byte-identical** between v10.11.5 and v12.0
|
||||||
|
(`diff` produced no output). Fields in v12.0: `LocalAddress`, `ServerName`, `Version`,
|
||||||
|
`ProductName`, `OperatingSystem`, `Id`, `StartupWizardCompleted`.
|
||||||
|
|
||||||
|
The route `[HttpGet("Info/Public")]` sits at `SystemController.cs:92` in **both** versions, with
|
||||||
|
no `[Authorize]` attribute (anonymous), and is present in the 12.0 OpenAPI spec as
|
||||||
|
`/System/Info/Public`.
|
||||||
|
|
||||||
|
Sources: source diff of both tags; `https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json`
|
||||||
|
|
||||||
|
**The only delta is the value of `Version`:** `"12.0.0"` instead of `"10.11.x"`. Confirmed by the
|
||||||
|
blog: "the server reports its version as `12.0.0`" —
|
||||||
|
`https://jellyfin.org/posts/jellyfin-release-12.0`
|
||||||
|
|
||||||
|
Both uses JellyTau makes of this endpoint (version detection, offline-recovery probe) remain valid.
|
||||||
|
Version *parsing* is the thing to fix.
|
||||||
|
|
||||||
|
### 2.6 `/emby/*` and `/mediabrowser/*` route prefixes removed
|
||||||
|
|
||||||
|
`Jellyfin.Api/Middleware/LegacyEmbyRouteRewriteMiddleware.cs` **exists in v10.11.5 and is deleted
|
||||||
|
in v12.0**. Verified by `grep -rln '/emby' --include='*.cs'` over both trees: the file is listed
|
||||||
|
for 10.11.5 and absent for 12.0.
|
||||||
|
|
||||||
|
Release note: "Legacy route prefixes removed (`/emby/*` and `/mediabrowser/*`). Old third-party
|
||||||
|
clients that rely on them will stop working."
|
||||||
|
Source: `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v12.0`
|
||||||
|
|
||||||
|
**Not applicable to JellyTau** — grep found no `/emby/` or `/mediabrowser/` prefix usage.
|
||||||
|
|
||||||
|
### 2.7 BaseItemDto — purely additive, nothing removed or renamed
|
||||||
|
|
||||||
|
`MediaBrowser.Model/Dto/BaseItemDto.cs` diff between v10.11.5 and v12.0 is **two added fields and
|
||||||
|
nothing else**:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
+ public float? AlbumNormalizationGain { get; set; }
|
||||||
|
+ public string OriginalLanguage { get; set; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Every field the task called out is **declared identically in both versions** (verified by
|
||||||
|
extracting the property declarations from both files):
|
||||||
|
|
||||||
|
| Field | Type (identical in 10.11.5 and 12.0) |
|
||||||
|
|---|---|
|
||||||
|
| `ImageTags` | `Dictionary<ImageType, string>` |
|
||||||
|
| `BackdropImageTags` | `string[]` |
|
||||||
|
| `ParentBackdropImageTags` | `string[]` |
|
||||||
|
| `ParentBackdropItemId` | `Guid?` |
|
||||||
|
| `ParentThumbImageTag` / `ParentPrimaryImageTag` | `string` |
|
||||||
|
| `UserData` | `UserItemDataDto` |
|
||||||
|
| `MediaStreams` | `MediaStream[]` |
|
||||||
|
| `MediaSources` | `MediaSourceInfo[]` |
|
||||||
|
| `RunTimeTicks` | `long?` |
|
||||||
|
| `IndexNumber` | `int?` |
|
||||||
|
| `ParentIndexNumber` | `int?` |
|
||||||
|
| `SeriesId` | `Guid?` |
|
||||||
|
| `SeasonId` | `Guid?` |
|
||||||
|
|
||||||
|
`MediaBrowser.Model/Dto/UserItemDataDto.cs` and `MediaBrowser.Model/Dto/MediaSourceInfo.cs` are
|
||||||
|
**byte-identical** between the two tags.
|
||||||
|
|
||||||
|
`MediaBrowser.Model/Entities/MediaStream.cs` adds two fields — `LocalizedLanguage`,
|
||||||
|
`LocalizedOriginal` — and rewrites the computed `DisplayTitle` to use pre-resolved localized names
|
||||||
|
(this is the `Accept-Language` header support). **No field removed, no type changed.**
|
||||||
|
|
||||||
|
Source: source diff of `v10.11.5` vs `v12.0`.
|
||||||
|
|
||||||
|
### 2.8 PlaybackInfo — request and response shape unchanged; behaviour changed
|
||||||
|
|
||||||
|
`Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs` (the POST body, carrying `DeviceProfile`)
|
||||||
|
is **byte-identical** between v10.11.5 and v12.0. `/Items/{itemId}/PlaybackInfo` is present in the
|
||||||
|
12.0 OpenAPI spec.
|
||||||
|
|
||||||
|
`MediaInfoController.cs` diff is 28 lines, all plumbing:
|
||||||
|
`GetPlaybackInfo(item, user)` → `GetPlaybackInfo(item, user, Request)` (to read `Accept-Language`),
|
||||||
|
and `SortMediaSources(info, maxStreamingBitrate)` → `SortMediaSources(info, maxStreamingBitrate, item.Id)`.
|
||||||
|
|
||||||
|
Source: source diff of `Jellyfin.Api/Controllers/MediaInfoController.cs` and
|
||||||
|
`Jellyfin.Api/Helpers/MediaInfoHelper.cs`.
|
||||||
|
|
||||||
|
### 2.9 DeviceProfile schema — near-identical, two changes
|
||||||
|
|
||||||
|
`MediaBrowser.Model/Dlna/` diff between v10.11.5 and v12.0:
|
||||||
|
|
||||||
|
| File | Result |
|
||||||
|
|---|---|
|
||||||
|
| `DeviceProfile.cs` | **byte-identical** |
|
||||||
|
| `DirectPlayProfile.cs` | **byte-identical** |
|
||||||
|
| `CodecProfile.cs` | **byte-identical** |
|
||||||
|
| `SubtitleProfile.cs` | **byte-identical** |
|
||||||
|
| `ProfileCondition.cs` | **byte-identical** |
|
||||||
|
| `TranscodingProfile.cs` | one change (below) |
|
||||||
|
| `ProfileConditionValue.cs` | one added enum member (below) |
|
||||||
|
|
||||||
|
**Change 1 — `TranscodingProfile.BreakOnNonKeyFrames` retired:**
|
||||||
|
|
||||||
|
```diff
|
||||||
|
[DefaultValue(false)]
|
||||||
|
+ [XmlIgnore]
|
||||||
|
[XmlAttribute("breakOnNonKeyFrames")]
|
||||||
|
- public bool BreakOnNonKeyFrames { get; set; }
|
||||||
|
+ [Obsolete("This is always false")]
|
||||||
|
+ public bool? BreakOnNonKeyFrames { get; set; }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Type widened `bool` → `bool?`.** Also dropped from the copy constructor, dropped from
|
||||||
|
`StreamInfo`, and the `breakOnNonKeyFrames` **query parameter is removed from every streaming
|
||||||
|
endpoint** (`DynamicHlsController`, `VideosController`, `AudioController`,
|
||||||
|
`UniversalAudioController` — 8 method signatures total). Unknown query params are ignored by
|
||||||
|
ASP.NET Core, so a client still sending it is harmless.
|
||||||
|
|
||||||
|
**Change 2 — `ProfileConditionValue` gains `VideoRotation = 26`**, with a matching
|
||||||
|
`TranscodeReason.VideoRotationNotSupported = 1 << 27`. Additive; existing enum values are
|
||||||
|
unchanged (`NumStreams` is still `25`). Release note: "Add VideoRotation profile condition for
|
||||||
|
Android TVs that do not support rotation metadata."
|
||||||
|
|
||||||
|
Source: source diff of both tags; `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v12.0`
|
||||||
|
|
||||||
|
### 2.10 🟠 New: HLS/DASH-container sources are no longer eligible for direct play
|
||||||
|
|
||||||
|
New in `MediaBrowser.Model/Dlna/StreamBuilder.cs` (v12.0):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private const string ManifestContainers = "hls,applehttp,dash";
|
||||||
|
…
|
||||||
|
// A manifest is not a byte stream, so it cannot be handed to the client as one. The variant
|
||||||
|
// and segment URIs inside it are relative to the origin and do not resolve against the
|
||||||
|
// Jellyfin url the client would fetch it from.
|
||||||
|
if (ContainerHelper.ContainsContainer(ManifestContainers, item.Container))
|
||||||
|
{
|
||||||
|
isEligibleForDirectPlay = false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A source whose container is `hls`/`applehttp`/`dash` that direct-played on 10.11.5 will now be
|
||||||
|
transcoded/remuxed. Source: `StreamBuilder.cs` diff, hunk `@@ -714,6 +720,14 @@`.
|
||||||
|
|
||||||
|
### 2.11 🟠 TranscodeReasons now reports codec mismatches that 10.11.5 silently omitted
|
||||||
|
|
||||||
|
New in v12.0 `StreamBuilder.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
playlistItem.VideoCodecs = videoCodecs;
|
||||||
|
if (videoStream is not null && !ContainerHelper.ContainsContainer(videoCodecs, false, videoStream.Codec))
|
||||||
|
{
|
||||||
|
playlistItem.TranscodeReasons |= TranscodeReason.VideoCodecNotSupported;
|
||||||
|
}
|
||||||
|
…
|
||||||
|
if (audioStream is not null && audioStreamWithSupportedCodec is null)
|
||||||
|
{
|
||||||
|
playlistItem.TranscodeReasons |= TranscodeReason.AudioCodecNotSupported;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Source: `StreamBuilder.cs` diff, hunks `@@ -944,6 +958,10 @@` and `@@ -992,6 +1010,10 @@`.
|
||||||
|
|
||||||
|
This is a **reporting** improvement: PlaybackInfo responses now carry `VideoCodecNotSupported` /
|
||||||
|
`AudioCodecNotSupported` in cases where 10.11.5 returned an empty or partial reason set. If any
|
||||||
|
JellyTau workaround keys off "TranscodeReasons was empty so the profile must have been honoured",
|
||||||
|
that inference changes. See §3 for what this does **not** establish.
|
||||||
|
|
||||||
|
### 2.12 🔴 `GetItems` now defaults `recursive` to true for library folders with `includeItemTypes`
|
||||||
|
|
||||||
|
New in v12.0 `ItemsController.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
else if (folder is ICollectionFolder && includeItemTypes.Length == 0)
|
||||||
|
{
|
||||||
|
includeItemTypes = collectionType switch { CollectionType.boxsets => [BaseItemKind.BoxSet], _ => [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// includeItemTypes on a library lists its contents recursively rather than just its
|
||||||
|
// immediate children, so default to a recursive query when the client didn't choose.
|
||||||
|
if (folder is ICollectionFolder && includeItemTypes.Length > 0)
|
||||||
|
{
|
||||||
|
recursive ??= true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
and, at the user root, filtered requests now take the query path:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
-if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder)
|
||||||
|
+if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder || query.HasFilters)
|
||||||
|
```
|
||||||
|
|
||||||
|
Source: `Jellyfin.Api/Controllers/ItemsController.cs` diff (703 lines), hunks `@@ -294,7 +321,22 @@`
|
||||||
|
and `@@ -307,220 +349,273 @@`.
|
||||||
|
|
||||||
|
Release-note wording: "`GetItems` is now asynchronous and applies `recursive` when filters are
|
||||||
|
requested, limited to requests that include `includeItemTypes`. **The same query can return a
|
||||||
|
different result set than it did on 10.11.**"
|
||||||
|
Source: `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v12.0`; also
|
||||||
|
`https://jellyfin.org/posts/jellyfin-release-12.0`
|
||||||
|
|
||||||
|
This applies equally to the deprecated `/Users/{userId}/Items` alias, which routes to the same
|
||||||
|
handler.
|
||||||
|
|
||||||
|
**JellyTau audit item:** any request that sends `ParentId=<library>` **plus** `IncludeItemTypes`
|
||||||
|
**without** an explicit `Recursive` will change behaviour. The hard-coded query strings in
|
||||||
|
`repository/online.rs` all pair `IncludeItemTypes` with `Recursive=true`, but the dynamically
|
||||||
|
appended ones do not obviously do so — check `repository/endpoints.rs:172, 263, 315, 353, 367` and
|
||||||
|
`repository/online.rs:1259, 1492, 2246, 2947`. **Sending `Recursive` explicitly makes the
|
||||||
|
behaviour identical on both generations** — again a rename-class fix, not a capability branch.
|
||||||
|
|
||||||
|
### 2.13 🟠 HLS controllers removed from the OpenAPI spec (routes still live)
|
||||||
|
|
||||||
|
`Jellyfin.Api/Controllers/DynamicHlsController.cs` gains a class-level
|
||||||
|
`[ApiExplorerSettings(IgnoreApi = true)]` in v12.0 (it had none in 10.11.5), as does
|
||||||
|
`HlsSegmentController.cs`. Release note: "The HLS controllers are hidden from the specification."
|
||||||
|
|
||||||
|
**The routes still exist and still work in v12.0**, confirmed in source:
|
||||||
|
|
||||||
|
| Route | v12.0 location |
|
||||||
|
|---|---|
|
||||||
|
| `GET/HEAD /Videos/{itemId}/master.m3u8` | `DynamicHlsController.cs:404-405` |
|
||||||
|
| `GET/HEAD /Audio/{itemId}/master.m3u8` | `DynamicHlsController.cs:577-578` |
|
||||||
|
| `GET /Videos/{itemId}/main.m3u8` | `DynamicHlsController.cs:745` |
|
||||||
|
| `GET /Videos/{itemId}/live.m3u8` | `DynamicHlsController.cs:164` |
|
||||||
|
| `GET /Videos/{itemId}/hls1/{playlistId}/{segmentId}.{container}` | `DynamicHlsController.cs:1086` |
|
||||||
|
|
||||||
|
But they are **absent from the 12.0 OpenAPI spec**. Grepping
|
||||||
|
`https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json` for m3u8/stream/universal paths
|
||||||
|
returns only: `/Audio/{itemId}/stream`, `/Audio/{itemId}/stream.{container}`,
|
||||||
|
`/Audio/{itemId}/universal`, `/Videos/{itemId}/stream`, `/Videos/{itemId}/stream.{container}`,
|
||||||
|
`/Videos/{itemId}/Trickplay/{width}/tiles.m3u8`,
|
||||||
|
`/Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8`, plus LiveTv paths.
|
||||||
|
**`/Videos/{itemId}/master.m3u8` is not among them.**
|
||||||
|
|
||||||
|
Combined with the stated policy ("can be removed in any major release without warning"), JellyTau's
|
||||||
|
transcoded-playback path — which depends on `master.m3u8` — is now on **unspecified-but-functional**
|
||||||
|
footing. It works on 12.0; it carries removal risk for 13.0. This is a risk to track, not a
|
||||||
|
behavioural difference to branch on.
|
||||||
|
|
||||||
|
`GET/HEAD /Audio/{itemId}/universal` (`UniversalAudioController.cs:92-93`) and
|
||||||
|
`/Videos/{itemId}/stream` remain **in** the spec.
|
||||||
|
|
||||||
|
### 2.14 `StartTimeTicks` — unchanged
|
||||||
|
|
||||||
|
`long? startTimeTicks` appears in the same **11 method signatures** across
|
||||||
|
`VideosController.cs`, `AudioController.cs` and `DynamicHlsController.cs` in **both** v10.11.5 and
|
||||||
|
v12.0. Source: grep count over both trees.
|
||||||
|
|
||||||
|
One related fix in `StreamInfo.cs`: the master.m3u8 URL builder no longer emits a stray `?`
|
||||||
|
(10.11.5 appended `"/master.m3u8?"` then later `'?'`/`'&'`; 12.0 appends `"/master.m3u8"` and
|
||||||
|
rewrites the first `&` to `?`). This only affects server-generated URLs.
|
||||||
|
|
||||||
|
### 2.15 🟠 Image endpoints no longer upscale
|
||||||
|
|
||||||
|
New in v12.0 `MediaBrowser.Model/Drawing/DrawingUtils.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// Scales a size down uniformly until it fits inside a bounding box.
|
||||||
|
/// Returns the original size if it already fits, so this never upscales.
|
||||||
|
public static ImageDimensions ScaleDownToFit(ImageDimensions size, ImageDimensions boundingBox)
|
||||||
|
```
|
||||||
|
|
||||||
|
Blog: "Artwork is no longer stretched past its real size. Low resolution posters now appear at
|
||||||
|
their actual size instead of being blown up to fit."
|
||||||
|
Sources: `DrawingUtils.cs` diff; `https://jellyfin.org/posts/jellyfin-release-12.0`
|
||||||
|
|
||||||
|
A request for `?fillWidth=400` against a 200px-wide source now returns a ~200px image on 12.0 and a
|
||||||
|
400px image on 10.11.5. Layouts that assume the returned image matches the requested dimensions
|
||||||
|
will see different intrinsic sizes.
|
||||||
|
|
||||||
|
### 2.16 Other confirmed API-surface changes (obsolete-but-functional)
|
||||||
|
|
||||||
|
From `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v12.0`, each verified as an
|
||||||
|
`[Obsolete]` attribute present in the v12.0 controller source (file:line from the extracted tree):
|
||||||
|
|
||||||
|
| Endpoint | Replacement | v12.0 source |
|
||||||
|
|---|---|---|
|
||||||
|
| `GetTrailers` | `GetItems` with `includeItemTypes=Trailer` | `TrailersController.cs:125` |
|
||||||
|
| `GetArtists`, `GetAlbumArtists` | `GetPersons` | `ArtistsController.cs:90, 244` |
|
||||||
|
| `GetArtistByName` | `GetPerson` | `ArtistsController.cs:368` |
|
||||||
|
| `GetMusicGenre` | `GetGenre` | `MusicGenresController.cs:154` |
|
||||||
|
| `GetInstantMixFromMusicGenreBy{Id,Name}` | `GetInstantMixFromItem` | `InstantMixController.cs:199, 363` |
|
||||||
|
| `GetStartupConfiguration`, `UpdateInitialConfiguration`, `SetRemoteAccess` | configuration endpoints | `StartupController.cs:56, 76, 95` |
|
||||||
|
|
||||||
|
Also confirmed from the release notes: "`ItemByName` responses are restricted and people are
|
||||||
|
deduplicated"; sorting by name now uses `SortName`/`CleanName` so library ordering may differ;
|
||||||
|
`.ogg` is audio-only; global subtitle configuration removed in favour of per-library settings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 3 — Unverified / could not establish
|
||||||
|
|
||||||
|
Everything below was actively looked for and **could not be confirmed**. Treat each as unknown.
|
||||||
|
|
||||||
|
1. **Whether 12.0 honours a submitted `DirectPlayProfile`'s declared container and video codec any
|
||||||
|
differently from 10.11.5.** This was the central question behind JellyTau's workarounds and I
|
||||||
|
**cannot answer it.** What I established is narrower: 12.0 *reports* `VideoCodecNotSupported` /
|
||||||
|
`AudioCodecNotSupported` in `TranscodeReasons` where 10.11.5 did not (§2.11), and 12.0 refuses
|
||||||
|
direct play for HLS/DASH-container sources (§2.10). Neither tells you whether the *decision*
|
||||||
|
about a declared container/codec changed. `DirectPlayProfile.cs` is byte-identical and the rest
|
||||||
|
of `StreamBuilder.cs`'s direct-play evaluation shows no relevant change across its 18 diff
|
||||||
|
hunks, which is weak evidence for "no change" — but I did not trace the full decision path, and
|
||||||
|
I did not run either server. **Do not remove any existing workaround on the strength of this
|
||||||
|
report.** Verify empirically against a real 12.0 instance.
|
||||||
|
|
||||||
|
2. **Which specific 10.11.5 profile-ignoring defect each JellyTau workaround exists for.** I did
|
||||||
|
not read the workarounds or their originating issues, so I cannot say whether any is now
|
||||||
|
unnecessary, still necessary, or actively harmful on 12.0.
|
||||||
|
|
||||||
|
3. **Whether `EnableLegacyAuthorization` will be removed entirely in 13.0.** PR #15559 said
|
||||||
|
removal was expected "likely 10.13" (i.e. 13.0 under the new scheme), but that is a 2025-11
|
||||||
|
statement about a plan, not a commitment, and the flag still exists in 12.0. The 12.0 release
|
||||||
|
notes do not restate a removal target.
|
||||||
|
|
||||||
|
4. **Whether real-world 12.0 servers will have `EnableLegacyAuthorization` re-enabled by users.**
|
||||||
|
The setting is user-editable in `system.xml` and some users will flip it back to keep older
|
||||||
|
clients working. A client cannot read this setting (it is not in `/System/Info/Public`), so
|
||||||
|
**there is no way to detect it other than attempting a request and observing 401.** Do not
|
||||||
|
assume "server is 12.0" implies "legacy auth is off".
|
||||||
|
|
||||||
|
5. **The exact HTTP status/body returned when a legacy auth method is rejected.** I did not run a
|
||||||
|
12.0 server. I assume 401 based on the authorization pipeline but **did not verify it**, and I
|
||||||
|
did not establish whether a rejected `X-Emby-Authorization` produces a distinguishable error
|
||||||
|
from an expired token — which matters if you want to auto-detect and re-auth.
|
||||||
|
|
||||||
|
6. **Whether `/Users/{userId}/…` routes emit a deprecation warning header** (e.g. `Deprecation`,
|
||||||
|
`Sunset`, `Warning`) on 12.0. I looked at the controllers and found only `[Obsolete]` /
|
||||||
|
`[ApiExplorerSettings]` compile-time and spec-time attributes. I found no evidence of a runtime
|
||||||
|
response header, but did not exhaustively search the middleware pipeline.
|
||||||
|
|
||||||
|
7. **A 10.11.x OpenAPI document for a true spec-to-spec diff.** `api.jellyfin.org` serves only one
|
||||||
|
spec and it is now `12.0.0`; both the "stable" and "unstable" URLs return the identical
|
||||||
|
1,894,898-byte 12.0 document. The `jellyfin-sdk-typescript` repo's historic `openapi.json` files
|
||||||
|
are **Git LFS pointers**, which I did not resolve. All route/DTO comparisons in this report are
|
||||||
|
therefore from **C# source**, not from two specs. Source-level results should be equivalent or
|
||||||
|
better, but the difference is worth stating.
|
||||||
|
|
||||||
|
8. **Changes to `POST /Sessions/Playing`, `/Sessions/Playing/Progress`, `/Sessions/Playing/Stopped`
|
||||||
|
payload semantics**, and to remote-control / session-polling behaviour. `PlaystateController.cs`
|
||||||
|
shows the routes intact with unchanged obsolete markers, but I did not diff the session
|
||||||
|
manager, `SessionInfo`, or the WebSocket message set. JellyTau's remote mode depends on these
|
||||||
|
and they were **not examined**.
|
||||||
|
|
||||||
|
9. **Whether the `Accept-Language` header support changes any response JellyTau parses.**
|
||||||
|
`MediaStream.DisplayTitle` is now built from server-resolved `LocalizedLanguage` rather than
|
||||||
|
client-side culture lookup, which means `DisplayTitle` **strings will differ** — but I did not
|
||||||
|
determine the default when no `Accept-Language` is sent, nor whether JellyTau parses
|
||||||
|
`DisplayTitle` anywhere.
|
||||||
|
|
||||||
|
10. **Any change to `/Items/{itemId}/Images/{type}` URL parameters** (`tag`, `maxWidth`,
|
||||||
|
`fillHeight`, `quality`). I confirmed the *upscaling* behaviour change (§2.15) but did not diff
|
||||||
|
`ImageController`'s parameter list.
|
||||||
|
|
||||||
|
11. **Download / sync / offline endpoints** (`/Items/{id}/Download`, `/Sync/*`). Not examined.
|
||||||
|
|
||||||
|
12. **`/Videos/{id}/stream` `static=true` semantics** — whether the container/`mediaSourceId`
|
||||||
|
handling changed. `VideosController.cs` has a 207-line diff dominated by the
|
||||||
|
`PrimaryVersionId` `string` → `Guid` refactor and alternate-version relinking; I did not
|
||||||
|
isolate whether any of it alters `static=true` responses.
|
||||||
|
|
||||||
|
13. **Whether 12.0 changes the `DeviceId` single-session constraint** mentioned in the auth gist.
|
||||||
|
Not investigated.
|
||||||
|
|
||||||
|
14. **Actual 12.0 runtime behaviour of anything.** Nothing in this report was tested against a
|
||||||
|
running server of either version. Everything is source, spec, and release-note analysis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 4 — Proposed capability flags
|
||||||
|
|
||||||
|
The strongest finding here is that **most of this needs no flag.** Four of the five headline
|
||||||
|
changes are fixed by writing the request in a way that is correct on *both* generations. Flags
|
||||||
|
should be reserved for genuine either/or behaviour, because each one is a silent branch that will
|
||||||
|
outlive the reason it was added.
|
||||||
|
|
||||||
|
### Needs no flag — fix once, works on both generations
|
||||||
|
|
||||||
|
| Change | Fix | Why no flag |
|
||||||
|
|---|---|---|
|
||||||
|
| §2.3 auth header | `X-Emby-Authorization` → `Authorization`, same value | `Authorization` + `MediaBrowser` scheme is ungated in 10.11.5 and 12.0 |
|
||||||
|
| §2.3 query auth | `api_key=` → `ApiKey=` | `ApiKey` is ungated in both; the server itself emits it in both |
|
||||||
|
| §2.12 recursive default | send `Recursive` explicitly on every `IncludeItemTypes` query | an explicit value makes both generations agree |
|
||||||
|
| §2.9 breakOnNonKeyFrames | stop sending it | ignored as an unknown query param on both |
|
||||||
|
| §2.2 user-scoped routes | optional: migrate to `/Items?userId=` etc. | replacements exist in 10.11.5 too |
|
||||||
|
|
||||||
|
Do these first. They eliminate the entire breaking surface without introducing a single branch.
|
||||||
|
|
||||||
|
### Genuinely version-dependent — flag candidates
|
||||||
|
|
||||||
|
A single detected generation, derived once from `/System/Info/Public` `Version`, should drive these:
|
||||||
|
|
||||||
|
```
|
||||||
|
ServerGeneration::V10_11 // Version major == 10
|
||||||
|
ServerGeneration::V12Plus // Version major >= 12
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Guards | Default 10.11.x | Default 12.x | Source |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `supports_manifest_container_direct_play` | Whether an `hls`/`applehttp`/`dash` source may be direct-played | `true` | `false` | §2.10 |
|
||||||
|
| `reports_codec_transcode_reasons` | Whether an empty/partial `TranscodeReasons` can be read as "profile honoured" | `false` | `true` | §2.11 |
|
||||||
|
| `image_endpoint_upscales` | Whether a requested `fillWidth`/`maxWidth` is the size you get back | `true` | `false` | §2.15 |
|
||||||
|
| `hls_master_playlist_in_spec` | Whether `/Videos/{id}/master.m3u8` is a specified endpoint (removal-risk telemetry, not a behaviour switch) | `true` | `false` | §2.13 |
|
||||||
|
|
||||||
|
### Runtime-probed, not version-derived
|
||||||
|
|
||||||
|
| Flag | Why it cannot be version-derived |
|
||||||
|
|---|---|
|
||||||
|
| `legacy_auth_accepted` | A 12.0 admin can set `EnableLegacyAuthorization=true`, and a 10.11 admin can set it to `false`. Not exposed to clients (§3.4). If JellyTau keeps any legacy-auth fallback, it must be probe-and-observe-401, never version-inferred. **Better: send only non-deprecated auth and delete the concept.** |
|
||||||
|
|
||||||
|
### Version parsing
|
||||||
|
|
||||||
|
Whatever detects the generation must **not** assume a leading `10.`. `/System/Info/Public` returns
|
||||||
|
`"10.11.5"` on one generation and `"12.0.0"` on the other; under the old scheme 12.0 would have been
|
||||||
|
10.12.0, so `major >= 12` and `major == 10` are the two live cases and `major == 11` will never
|
||||||
|
occur. The Jellyfin blog explicitly flags version-string parsers as the thing to check before
|
||||||
|
upgrading (`https://jellyfin.org/posts/jellyfin-release-12.0`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Source index
|
||||||
|
|
||||||
|
| # | URL |
|
||||||
|
|---|---|
|
||||||
|
| 1 | `https://api.github.com/repos/jellyfin/jellyfin/tags` (pages 1-8) |
|
||||||
|
| 2 | `https://api.github.com/repos/jellyfin/jellyfin/releases/latest` |
|
||||||
|
| 3 | `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v12.0` |
|
||||||
|
| 4 | `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v10.11.11` |
|
||||||
|
| 5 | `https://jellyfin.org/posts/jellyfin-release-12.0` |
|
||||||
|
| 6 | `https://jellyfin.org/posts/` |
|
||||||
|
| 7 | `https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json` (`info.version` = 12.0.0) |
|
||||||
|
| 8 | `https://gist.github.com/nielsvanvelzen/ea047d9028f676185832e51ffaf12a6f` (auth methods table) |
|
||||||
|
| 9 | `https://api.github.com/repos/jellyfin/jellyfin/pulls/13306` |
|
||||||
|
| 10 | `https://api.github.com/repos/jellyfin/jellyfin/pulls/15559` |
|
||||||
|
| 11 | `https://api.github.com/repos/jellyfin/jellyfin/pulls/16754` |
|
||||||
|
| 12 | `https://api.github.com/repos/jellyfin/jellyfin/pulls/16992` |
|
||||||
|
| 13 | `https://codeload.github.com/jellyfin/jellyfin/tar.gz/refs/tags/v10.11.5` (full source tree) |
|
||||||
|
| 14 | `https://codeload.github.com/jellyfin/jellyfin/tar.gz/refs/tags/v12.0` (full source tree) |
|
||||||
|
| 15 | `https://raw.githubusercontent.com/jellyfin/jellyfin/v12.0/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs` |
|
||||||
|
| 16 | `https://raw.githubusercontent.com/jellyfin/jellyfin/v10.11.5/MediaBrowser.Model/Configuration/ServerConfiguration.cs` |
|
||||||
|
| 17 | `https://raw.githubusercontent.com/jellyfin/jellyfin/v12.0/MediaBrowser.Model/Configuration/ServerConfiguration.cs` |
|
||||||
|
|
||||||
|
Working files (source trees, diffs, route diff JSON) are retained in the scratchpad alongside this
|
||||||
|
report: `tree/jellyfin-10.11.5/`, `tree/jellyfin-12.0/`, `routediff.json`, `sb.diff`, `items.diff`,
|
||||||
|
`v12-body.md`, `oas-stable.json`.
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
# Spec: Jellyfin server version compatibility
|
||||||
|
|
||||||
|
**Status:** Partially implemented
|
||||||
|
**Requirements:** UR-085 → IR-035, JA-037, DR-279 … DR-288 (DR-287 and DR-288
|
||||||
|
were added once research established what actually breaks).
|
||||||
|
|
||||||
|
## What is left
|
||||||
|
|
||||||
|
Everything below shipped on 2026-09-08 **except**:
|
||||||
|
|
||||||
|
- **DR-283 is partially done.** `supports_manifest_container_direct_play` and
|
||||||
|
`image_endpoint_upscales` are resolved and tested, but **nothing consumes them
|
||||||
|
yet** — and that may be correct rather than an omission: on 12.0 the *server*
|
||||||
|
enforces both (it refuses direct play for manifest containers itself, and
|
||||||
|
simply returns the smaller image), so the client learns the answer from the
|
||||||
|
`PlaybackInfo` response without needing to predict it. Decide whether to
|
||||||
|
consume them or delete them once a running 12.x server can be observed. Do not
|
||||||
|
leave them unread indefinitely: an unconsumed flag is a branch waiting to be
|
||||||
|
wired wrongly.
|
||||||
|
- **`honours_directplay_audio_codec` is unresolved for 12.x.** A source-level
|
||||||
|
diff could not establish whether the behaviour changed. The override stays on
|
||||||
|
for both generations. Flip it only against a running 12.x server — keeping it
|
||||||
|
costs an unnecessary transcode, removing it wrongly costs silent playback.
|
||||||
|
- **The user-scoped route migration (DR-282) was not performed.** It is not
|
||||||
|
needed: the whole family still works on 12.0. Both route shapes are built and
|
||||||
|
tested, so switching is a one-line change whenever it is wanted.
|
||||||
|
- **Nothing was tested against a real server of either generation.** Every
|
||||||
|
cross-generation assertion runs against a mock built from a source-level diff.
|
||||||
|
|
||||||
|
## What research established
|
||||||
|
|
||||||
|
The framing this spec was written under was wrong in a way worth recording.
|
||||||
|
|
||||||
|
**Jellyfin 11.0 does not exist and never did.** With 12.0 the project dropped the
|
||||||
|
leading `10` from its version scheme: what would have been 10.12.0 shipped as
|
||||||
|
`12.0`, and the server reports `Version: "12.0.0"`. So "two generations" means
|
||||||
|
**10.11.x and 12.x**, one release-branch step apart, not two majors. 12.0 became
|
||||||
|
stable on 2026-09-08 — the same day this work was done — so real-world 12.x
|
||||||
|
installs are currently near zero and rising.
|
||||||
|
|
||||||
|
The delta is far smaller than this spec assumed, and almost none of it is a
|
||||||
|
branch:
|
||||||
|
|
||||||
|
| Finding | Consequence |
|
||||||
|
|---|---|
|
||||||
|
| `X-Emby-Authorization` and the `api_key` query parameter are **disabled by default in 12.0**, including on upgraded servers via a migration | The one genuinely breaking change. Fixed by a **rename** — `Authorization` + `ApiKey` are ungated on both — not a flag (DR-287) |
|
||||||
|
| `GetItems` now defaults `recursive` to true for a library parent with `IncludeItemTypes` | The same request returns a different result set. Fixed by stating `Recursive` explicitly (DR-288) |
|
||||||
|
| The `/Users/{userId}/…` family **survives** in 12.0 | No migration needed. Six routes were removed in total; none are ones this client calls |
|
||||||
|
| `BaseItemDto` is **purely additive**; `DeviceProfile`, `PlaybackInfo`, `PublicSystemInfo` byte-identical | No DTO work at all |
|
||||||
|
| Manifest-container sources are no longer direct-play eligible; image endpoints no longer upscale | The only two genuine either/or differences — and both are server-enforced |
|
||||||
|
|
||||||
|
The lesson for the layer rule: **most of a version delta is fixed by writing the
|
||||||
|
request correctly for both generations, not by branching on the version.** Flags
|
||||||
|
are for genuine either/or behaviour, because each one is a silent branch that
|
||||||
|
outlives the reason it was added.
|
||||||
|
|
||||||
|
The full report, with a source URL per claim, is
|
||||||
|
[jellyfin-12-api-delta.md](jellyfin-12-api-delta.md).
|
||||||
|
**UX spec:** n/a for the bulk of it. One new user-visible state — "this server
|
||||||
|
is a version JellyTau does not know" — needs a home in the connect flow; see
|
||||||
|
DR-286.
|
||||||
|
**Supersedes / revises:** nothing. Touches
|
||||||
|
[backend-owned-stream-selection.md](backend-owned-stream-selection.md) at the
|
||||||
|
`StreamSelection` boundary and should land after it where they overlap, but
|
||||||
|
neither blocks the other.
|
||||||
|
|
||||||
|
**Destination on completion:**
|
||||||
|
[01-rust-backend.md](../architecture/01-rust-backend.md) — a new "Server
|
||||||
|
capability negotiation" section beside "Domain Vocabulary Owned by Rust", which
|
||||||
|
is where the litmus test this feature exists to satisfy already lives; and a
|
||||||
|
paragraph in [07-connectivity.md](../architecture/07-connectivity.md) noting
|
||||||
|
that the `/System/Info/Public` probe now has a second consumer. The durable half
|
||||||
|
is the capability model and *why* it is flags rather than version comparisons;
|
||||||
|
phases, ticket boundaries and acceptance criteria are disposable.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Let one build of JellyTau talk to more than one generation of Jellyfin server.
|
||||||
|
The app already asks the server what version it is, at connect, before login —
|
||||||
|
and then throws the answer away. Instead it resolves that version into a
|
||||||
|
`ServerCapabilities` value once per connection, and every decision that depends
|
||||||
|
on the server generation reads a named flag from it.
|
||||||
|
|
||||||
|
Nothing about the app changes for a user whose server matches what the code
|
||||||
|
targets today. What changes is that the release which follows the server forward
|
||||||
|
stops silently abandoning everyone who has not upgraded, and that a server the
|
||||||
|
app does not recognise produces a sentence rather than a cascade of parse
|
||||||
|
failures.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
The server and its clients are upgraded by different people on different
|
||||||
|
schedules. A family server can sit a major version behind for a year while the
|
||||||
|
phone updates itself weekly. Today the code has no way to express that.
|
||||||
|
|
||||||
|
**1. One server generation is hard-coded, unconditionally.** The current target
|
||||||
|
is 10.11.5 and it is written into the code as fact, not as a branch —
|
||||||
|
[device_profile.rs:336](../../src-tauri/src/repository/device_profile.rs#L336),
|
||||||
|
[online.rs:966](../../src-tauri/src/repository/online.rs#L966),
|
||||||
|
[online.rs:2271](../../src-tauri/src/repository/online.rs#L2271), and most
|
||||||
|
pointedly [online.rs:4667](../../src-tauri/src/repository/online.rs#L4667),
|
||||||
|
which is documented as "the override that exists because Jellyfin 10.11.5
|
||||||
|
ignores…". Every one of those is correct for one server and wrong for another,
|
||||||
|
and there is nowhere to say which.
|
||||||
|
|
||||||
|
**2. Endpoints are 57 inline string literals, not a route table.** They are
|
||||||
|
built with `format!` at the point of use, query string and all —
|
||||||
|
[online.rs:1957](../../src-tauri/src/repository/online.rs#L1957) is
|
||||||
|
representative. Supporting a second route shape without a table means 57
|
||||||
|
conditionals rather than one.
|
||||||
|
|
||||||
|
**3. The legacy user-scoped routes are load-bearing.** Roughly twelve sites use
|
||||||
|
`/Users/{uid}/Items`, `/Users/{uid}/Items/Resume`, `/Users/{uid}/Views`,
|
||||||
|
`/Users/{uid}/FavoriteItems/{id}` and `/Users/{uid}/PlayedItems/{id}`. These are
|
||||||
|
precisely the routes upstream has been moving away from in favour of
|
||||||
|
`/Items?userId=`. Whichever release drops them takes the app with it.
|
||||||
|
|
||||||
|
**4. There is no way to test any of this.** `src-tauri/` contains no HTTP mocking
|
||||||
|
at all — no `wiremock`, no `mockito`, no `httpmock`. Every test of the online
|
||||||
|
repository asserts on a *constructed URL string*; not one exercises a response.
|
||||||
|
So there is currently no mechanism by which "works against both generations"
|
||||||
|
could be demonstrated, and this is the single largest item in the work. It is
|
||||||
|
also worth doing on its own merits: a 4,797-line adapter with no response-level
|
||||||
|
tests is under-covered regardless of how many server versions it supports.
|
||||||
|
|
||||||
|
**5. A Jellyfin route is being built in the frontend.**
|
||||||
|
[imageCache.ts:64](../../src/lib/services/imageCache.ts#L64) constructs
|
||||||
|
`${serverUrl}/Items/${itemId}/Images/${imageType}` in Svelte. By the litmus test
|
||||||
|
in this project's own spec template — *would this have to change if Jellyfin
|
||||||
|
changed its API?* — that is domain logic in the presentation layer. It is the
|
||||||
|
only one left, and this is the feature that makes it actively wrong rather than
|
||||||
|
merely misplaced.
|
||||||
|
|
||||||
|
**What this is not.** It is not multi-server support. Profiles are users on one
|
||||||
|
server ([profiles/store.rs](../../src-tauri/src/profiles/store.rs)), and that
|
||||||
|
does not change here. "Both versions at the same time" means one binary that
|
||||||
|
adapts to whichever server it is pointed at, not two servers connected at once.
|
||||||
|
|
||||||
|
## Layer assignment
|
||||||
|
|
||||||
|
| Logic / responsibility | Layer | Why it belongs there |
|
||||||
|
|------------------------|-------|----------------------|
|
||||||
|
| Server version string → capability flags | Rust | Domain vocabulary in the strictest sense: it changes when and only when Jellyfin's API changes. The template's litmus test answers this in one word. |
|
||||||
|
| Which route shape to use for a given call | Rust | Wire format. The frontend must not know that a route exists, let alone that there are two. |
|
||||||
|
| Image URL construction (**moving** out of `imageCache.ts`) | Rust | A Jellyfin route, therefore it changes with Jellyfin's API. Currently in the frontend; this feature is what turns that from untidy into broken. |
|
||||||
|
| Device-profile / `PlaybackInfo` override selection | Rust | Already Rust and staying there. Only the *gating* is new — the overrides stop being unconditional. |
|
||||||
|
| Whether a cache written against one server generation is still valid | Rust | A storage invariant. The frontend cannot see the server version and must not learn to. |
|
||||||
|
| Deciding a server is too old / too new to use | Rust | A domain judgement about an API, expressed as an opaque state on the wire. |
|
||||||
|
| How the "unsupported server" state is worded and where it appears in the connect flow | Frontend | Pure presentation. It changes if the UI is redesigned and not otherwise. The frontend renders an opaque state; it never compares a version. |
|
||||||
|
|
||||||
|
Borderline: none. The one row that could be argued is the last, and it splits
|
||||||
|
cleanly — Rust decides *that* the server is unsupported, the frontend decides
|
||||||
|
what that looks like. The frontend never receives a version number to reason
|
||||||
|
about, for the same reason it never receives an item-type list.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### `ServerCapabilities`
|
||||||
|
|
||||||
|
Resolved once, at connect, from a version the app already has.
|
||||||
|
`AuthManager::connect_to_server` ([auth/mod.rs:147](../../src-tauri/src/auth/mod.rs#L147))
|
||||||
|
already parses `PublicSystemInfo.version` and returns it in `ServerInfo`, and the
|
||||||
|
`servers` table already has a `version TEXT` column
|
||||||
|
([schema.rs:42](../../src-tauri/src/storage/schema.rs#L42)) that is written on
|
||||||
|
insert. Detection therefore costs nothing new; the value is simply discarded
|
||||||
|
today.
|
||||||
|
|
||||||
|
The resolved value hangs on `OnlineRepository` and is passed to the route table.
|
||||||
|
`OfflineRepository` has no server and no capabilities; `HybridRepository`
|
||||||
|
delegates. No `MediaRepository` method signature changes, so no caller above
|
||||||
|
`repository/` is touched.
|
||||||
|
|
||||||
|
**Flags, not comparisons.** Every capability is named for the behaviour it
|
||||||
|
governs — `user_scoped_item_routes`, `honours_directplay_container`,
|
||||||
|
`playback_info_respects_container` — and the version → flags mapping lives in
|
||||||
|
exactly one function. A `version < 11` scattered through call sites is the same
|
||||||
|
mistake as a taxonomy in the frontend: it re-derives a domain fact at the point
|
||||||
|
of use, and it is unreadable at the second occurrence. Flags also survive the
|
||||||
|
case the comparison cannot express, which is a backport.
|
||||||
|
|
||||||
|
### Route table
|
||||||
|
|
||||||
|
The ~30 distinct endpoints move into `repository/endpoints.rs`, each a function
|
||||||
|
taking `&ServerCapabilities` and returning the path. Everything in `online.rs`
|
||||||
|
already funnels through three helpers that take `endpoint: &str` —
|
||||||
|
`get_json`, `post_json`, `post_json_response`
|
||||||
|
([online.rs:315-459](../../src-tauri/src/repository/online.rs#L315-L459)) — so
|
||||||
|
the interception point exists and there are 32 call sites, not 57 literals.
|
||||||
|
|
||||||
|
This step is behaviour-preserving on its own and lands before anything depends
|
||||||
|
on it.
|
||||||
|
|
||||||
|
### Unknown versions
|
||||||
|
|
||||||
|
An unrecognised version resolves to the newest known capability set and is
|
||||||
|
recorded, not rejected — the app should keep working against a server that is
|
||||||
|
merely newer than the release. Rejection is reserved for a version below the
|
||||||
|
floor, where the failure is certain rather than likely. Either way the outcome
|
||||||
|
crosses the IPC boundary as an opaque state, never a version number.
|
||||||
|
|
||||||
|
### Cache validity
|
||||||
|
|
||||||
|
Cached rows carry no record of which server generation wrote them. The server's
|
||||||
|
version goes on the cache alongside the existing `synced_at`, and a change
|
||||||
|
invalidates by clearing `synced_at` — the same move
|
||||||
|
[MIGRATION_018 and migration 025](../../src-tauri/src/storage/schema.rs) already
|
||||||
|
make, and for the same reason: the association was never stored, so existing rows
|
||||||
|
cannot be repaired locally and must be re-fetched.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- **Multi-server support.** One server per install, as today.
|
||||||
|
- **Emby, or any non-Jellyfin server.** The capability model would carry it; the
|
||||||
|
DTO layer would not, and nothing here should be read as a step toward it.
|
||||||
|
- **The Windows/Linux/Android split.** Capabilities describe the *server*, never
|
||||||
|
the client platform. Platform differences stay in `device_profile.rs`.
|
||||||
|
- **Raising coverage of the whole online adapter.** The mock-server harness makes
|
||||||
|
that possible and the version-sensitive paths get tests; a general backfill is
|
||||||
|
separate work.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] The app connects, browses, plays and reports against both target server
|
||||||
|
generations, from one build, with no user-visible configuration.
|
||||||
|
- [ ] The repository suite runs against both generations' fixtures and passes.
|
||||||
|
- [ ] No `format!` endpoint literal remains in `online.rs`.
|
||||||
|
- [ ] No version comparison exists outside the single version → capabilities
|
||||||
|
function.
|
||||||
|
- [ ] A server below the supported floor produces one legible message; a server
|
||||||
|
newer than the release still works.
|
||||||
|
- [ ] `bun run check` and `bun run test` pass.
|
||||||
|
- [ ] `cargo fmt` clean, `cargo clippy --all-targets -D warnings` clean,
|
||||||
|
`bun run test:rust` passes.
|
||||||
|
- [ ] `bun run check:boundary` passes — and note it will *not* catch the
|
||||||
|
`imageCache.ts` route, which is why DR-285 is a ticket rather than a
|
||||||
|
tripwire.
|
||||||
|
- [ ] New requirement-implementing code carries `// TRACES:` comments;
|
||||||
|
`bun run traces:validate` passes and coverage does not fall.
|
||||||
|
- [ ] `bindings.ts` regenerated if Rust types changed.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The harness is the feature's precondition, not its afterthought.
|
||||||
|
|
||||||
|
**Rust.** Add a mock HTTP server (`wiremock` — a project dependency, so no CI
|
||||||
|
image change; see the toolchain rule in CLAUDE.md) plus one recorded fixture set
|
||||||
|
per server generation. The repository suite becomes parameterised over
|
||||||
|
generations. What must be covered: route selection per capability; the
|
||||||
|
device-profile overrides firing on the generation they were written for and *not*
|
||||||
|
on the other; cache invalidation across a version change; an unknown version
|
||||||
|
resolving forward rather than failing.
|
||||||
|
|
||||||
|
**Frontend.** `imageCache.ts` loses its URL construction, so its tests assert it
|
||||||
|
calls the command rather than that it builds a string.
|
||||||
|
|
||||||
|
`repository/online_integration_test.rs` **has been deleted** (2026-09-08). It was
|
||||||
|
never declared in `repository/mod.rs` and referenced a `crate::api::jellyfin`
|
||||||
|
module that does not exist, so it had never compiled. It is worth knowing why it
|
||||||
|
was not merely dead but harmful: its mock *reimplemented* the URL builders and
|
||||||
|
then asserted against itself, and `online.rs` carries a comment recording that
|
||||||
|
this exact arrangement once shipped a `/Videos/{id}/download` endpoint that 404s
|
||||||
|
on real servers while the mock happily tested the correct one — silently breaking
|
||||||
|
every movie and TV download. Its own `test_image_url_basic` asserted `api_key=`
|
||||||
|
appears in image URLs while the mock beside it documented the opposite.
|
||||||
|
|
||||||
|
That is the anti-pattern DR-281 exists to replace: assert against a *response*
|
||||||
|
from a mock **server**, never against a mock that re-derives the thing under
|
||||||
|
test.
|
||||||
|
|
||||||
|
## TRACES
|
||||||
|
|
||||||
|
| Piece | Suggested tag |
|
||||||
|
|---|---|
|
||||||
|
| `ServerCapabilities` + version resolution | `UR-085 \| IR-035, DR-280 \| UT-xxx` |
|
||||||
|
| `repository/endpoints.rs` | `UR-085 \| DR-279` |
|
||||||
|
| Route selection for user-scoped endpoints | `UR-085 \| JA-037, DR-282` |
|
||||||
|
| Capability-gated profile overrides | `UR-085 \| DR-283` |
|
||||||
|
| Cache generation stamp + invalidation | `UR-085 \| DR-284` |
|
||||||
|
| Image URL command | `UR-012, UR-085 \| DR-285` |
|
||||||
|
| Unsupported-server state | `UR-085 \| DR-286` |
|
||||||
|
|
||||||
|
## Notes for the implementer
|
||||||
|
|
||||||
|
- **The concrete API delta is not in this spec, deliberately.** No route, field
|
||||||
|
or behaviour difference between the two generations is asserted here, because
|
||||||
|
none has been verified against an upstream changelog. The first ticket exists
|
||||||
|
to establish it. Do not let a plausible-sounding difference enter the code
|
||||||
|
without a citation — a wrong capability flag is worse than none, since it fires
|
||||||
|
silently on the generation it was not tested against.
|
||||||
|
- The route table and the capability struct are independently useful and
|
||||||
|
independently reviewable. If the feature is cut, cut from the end, not the
|
||||||
|
start.
|
||||||
|
- A parallel Claude session may be active in this repo — `git diff` before
|
||||||
|
"repairing" unexpected changes.
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
# Spec: Multi-user profiles with PIN switching
|
||||||
|
|
||||||
|
**Status:** Proposed
|
||||||
|
**Requirements:** UR-082, UR-083, UR-084 → IR-034, DR-267 … DR-276
|
||||||
|
**UX spec:** [ux-flows.md](../ux-flows.md) — new "Who's watching" section
|
||||||
|
**Destination on completion:**
|
||||||
|
- [09-security.md](../architecture/09-security.md) — new "Profile locking" section beside *Authentication Token Storage* (PIN gate, what it does and does not protect)
|
||||||
|
- [01-rust-backend.md](../architecture/01-rust-backend.md) — profile switch orchestration beside the session state machine
|
||||||
|
- [02-svelte-frontend.md](../architecture/02-svelte-frontend.md) — profile picker + lock state in the nav guard
|
||||||
|
- [08-database-design.md](../architecture/08-database-design.md) — `user_pins`, `user_item_visibility`, `download_grants`, per-user vs device settings
|
||||||
|
- [06-downloads-and-offline.md](../architecture/06-downloads-and-offline.md) — shared files, per-user grants, refcounted deletion
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
A shared device (family TV, tablet) can hold several Jellyfin accounts **from the
|
||||||
|
same server** and switch between them in a couple of taps. Adult accounts can set
|
||||||
|
a numeric PIN that gates the switch; child accounts have no PIN and are one tap
|
||||||
|
away. An adult who forgets their PIN signs in with their Jellyfin password
|
||||||
|
instead — there is no separate reset flow.
|
||||||
|
|
||||||
|
The feature is **opt-in and invisible until used**: one account with no PIN
|
||||||
|
behaves exactly as the app does today.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
JellyTau already stores users per server ([schema.rs](../../src-tauri/src/storage/schema.rs)
|
||||||
|
`users`), keeps a token per user in the keyring, and exposes
|
||||||
|
`storage_get_users` / `storage_set_active_user` — none of which any UI calls. The
|
||||||
|
only way to change account today is `auth_logout`, which calls Jellyfin's logout
|
||||||
|
endpoint and **invalidates the token server-side**, forcing a full password login
|
||||||
|
every time. On a living-room device shared by a family that is the difference
|
||||||
|
between "switch to the kids' profile" and "find the password".
|
||||||
|
|
||||||
|
Two defects block simply exposing the existing commands, and both are the real
|
||||||
|
work of this spec:
|
||||||
|
|
||||||
|
1. **The metadata cache is server-scoped, not user-scoped.** `items`, `libraries`,
|
||||||
|
`genres` and `thumbnails` carry `server_id` but no user. Jellyfin's parental
|
||||||
|
controls filter *server responses*, but the cache-first read path returns local
|
||||||
|
rows before the server answers — so a child profile on a device a parent has
|
||||||
|
browsed sees the parent's titles and artwork.
|
||||||
|
2. **Download availability is answered per item, not per user.**
|
||||||
|
`offline_is_available` ([offline.rs](../../src-tauri/src/commands/offline.rs))
|
||||||
|
counts completed rows for an `item_id` with no user predicate, so a child's UI
|
||||||
|
marks a parent's download as available and can play it offline.
|
||||||
|
|
||||||
|
## Layer assignment
|
||||||
|
|
||||||
|
| Logic / responsibility | Layer | Why it belongs there |
|
||||||
|
|------------------------|-------|----------------------|
|
||||||
|
| Which profiles exist, and each one's unlock method | Rust | Derived from the `users` table + PIN presence. The frontend must never infer "this is a child account" from anything; it renders an opaque `unlock_method` |
|
||||||
|
| PIN verification, attempt counting, lockout window | Rust | A gate the frontend could skip is not a gate. The counter and the clock must live where the webview cannot reach them |
|
||||||
|
| PIN hashing (KDF, salt, cost) | Rust | Security primitive; changes with threat model, never with UI |
|
||||||
|
| Switch orchestration (stop player, drain sync queue, swap repository, restart poller) | Rust | Owns every piece of state being torn down; ordering is a correctness invariant |
|
||||||
|
| Cache visibility stamping and filtering | Rust | Domain data access control. Any leak here is a content-safety bug |
|
||||||
|
| Download grants, refcounted file deletion | Rust | Storage domain; the frontend has no concept of a file refcount |
|
||||||
|
| Same-server constraint on adding a profile | Rust | Domain rule about what a profile *is*, not a form-validation nicety |
|
||||||
|
| Whether to show the picker at startup | Rust | Depends on profile count + PIN presence + a stored setting, all backend state |
|
||||||
|
| Profile picker grid, avatars, transitions | Frontend | Pure presentation |
|
||||||
|
| PIN pad layout, digit entry, shake-on-wrong | Frontend | Input handling; changes only if the UI is redesigned |
|
||||||
|
| "Use password instead" form | Frontend | Presentation over the existing `auth_login` |
|
||||||
|
| Ordering of tiles (last used first) | Frontend | Presentation preference over data Rust already returns |
|
||||||
|
|
||||||
|
Borderline: *ordering of tiles* could be argued into Rust since `last_used_at`
|
||||||
|
comes from the DB. Rust returns the timestamp; the frontend decides it means
|
||||||
|
"leftmost". Tie-breaker: it changes only if the UI is redesigned.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Threat model — state it plainly
|
||||||
|
|
||||||
|
The PIN is a **switching gate against a member of the household**, not at-rest
|
||||||
|
protection against an attacker with the disk. Tokens stay in the keyring exactly
|
||||||
|
as they are today ([credentials.rs](../../src-tauri/src/credentials.rs)); the PIN
|
||||||
|
does **not** encrypt them.
|
||||||
|
|
||||||
|
This is a deliberate choice, and the rejected alternative matters enough to
|
||||||
|
record: wrapping each token with a key derived from its PIN would resist an
|
||||||
|
offline attacker, but a locked profile would then be *unable to act as itself* —
|
||||||
|
no resuming its downloads after a restart, no draining its `sync_queue`, no
|
||||||
|
session polling — until someone walked past and typed four digits. On a device
|
||||||
|
that reboots nightly that is a worse product for a threat this feature does not
|
||||||
|
face. A four-digit code was never going to resist an offline attack anyway.
|
||||||
|
|
||||||
|
Consequences to document in 09-security.md rather than discover later:
|
||||||
|
|
||||||
|
- Anyone with the SQLite file and keyring access has every profile's token,
|
||||||
|
PIN or not.
|
||||||
|
- The PIN stops a child *becoming a parent*. It does not restrict content. Content
|
||||||
|
restriction is Jellyfin's server-side parental controls, which most self-hosters
|
||||||
|
have never configured — the UI must say so when a PIN-less profile is created.
|
||||||
|
|
||||||
|
### Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Migration 024
|
||||||
|
CREATE TABLE user_pins (
|
||||||
|
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
pin_hash TEXT NOT NULL, -- Argon2id PHC string; salt is embedded
|
||||||
|
failed_count INTEGER DEFAULT 0,
|
||||||
|
locked_until TEXT, -- RFC3339; NULL when not locked out
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- What the server has actually shown to this user. NOT a maintained index:
|
||||||
|
-- written as a byproduct of the cache write path, so it cannot disagree with
|
||||||
|
-- what the server returned.
|
||||||
|
CREATE TABLE user_item_visibility (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, item_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_visibility_user ON user_item_visibility(user_id);
|
||||||
|
|
||||||
|
-- Same, at library granularity, from each user's /UserViews.
|
||||||
|
CREATE TABLE user_libraries (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
library_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, library_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Downloads: one file, many claimants.
|
||||||
|
CREATE TABLE download_grants (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
download_id INTEGER NOT NULL REFERENCES downloads(id) ON DELETE CASCADE,
|
||||||
|
granted_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, download_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Migration of existing installs is not optional.** Every current cache row and
|
||||||
|
download predates the concept of a user. Migration 024 backfills
|
||||||
|
`user_item_visibility` and `download_grants` for the single existing user (and,
|
||||||
|
if somehow several `users` rows exist, for the one with `is_active = 1`).
|
||||||
|
Without the backfill an upgrading user's library goes blank.
|
||||||
|
|
||||||
|
### Cache scoping — a byproduct, not an index
|
||||||
|
|
||||||
|
The reason this is tractable: the repository **already carries the user**.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct OfflineRepository {
|
||||||
|
db_service: Arc<RusqliteService>,
|
||||||
|
server_id: String,
|
||||||
|
user_id: String, // already there, already used for user_data joins
|
||||||
|
}
|
||||||
|
```
|
||||||
|
[offline.rs:87](../../src-tauri/src/repository/offline.rs#L87)
|
||||||
|
|
||||||
|
Every row enters the cache because *some specific user's* request returned it, and
|
||||||
|
`save_to_cache` ([offline.rs:395](../../src-tauri/src/repository/offline.rs#L395))
|
||||||
|
— the single write choke point, called only from
|
||||||
|
[hybrid.rs](../../src-tauri/src/repository/hybrid.rs) — knows who that is. It
|
||||||
|
stamps `user_item_visibility` in the same transaction as the item. There is no
|
||||||
|
reconciliation job and no way for the stamp to drift from what the server said,
|
||||||
|
because the stamp *is* the record of what the server said.
|
||||||
|
|
||||||
|
Reads join through it. 43 of the 64 `FROM items` sites live in
|
||||||
|
`repository/offline.rs`, where `self.user_id` is already in scope.
|
||||||
|
|
||||||
|
**The 21 sites outside the repository are triaged, not blanket-scoped:**
|
||||||
|
|
||||||
|
| Group | Disposition |
|
||||||
|
|-------|-------------|
|
||||||
|
| Browse/query paths returning lists to the UI | Must scope |
|
||||||
|
| By-id lookups from an already-authorised context (download worker resolving an item it holds a grant for; queued-row stream URL lookup) | Not scoped — authorisation happened upstream |
|
||||||
|
| Maintenance (`smart_cache` eviction, `pinning`) | Not scoped — deliberately device-wide |
|
||||||
|
|
||||||
|
The triage result is recorded in 08-database-design.md, because "why isn't this
|
||||||
|
one scoped?" is exactly what a future change gets wrong.
|
||||||
|
|
||||||
|
**Known limit — revocation drift.** The stamp can never show *more* than the
|
||||||
|
server showed, but it does not shrink when a parent tightens permissions. Mitigation:
|
||||||
|
on unlock while online, re-derive `user_libraries` from `/UserViews` and drop
|
||||||
|
visibility rows for libraries that disappeared. Offline, the cache stays
|
||||||
|
stale-permissive. This is a stated property, not a bug.
|
||||||
|
|
||||||
|
**Enforcement.** `scripts/check-cache-scope.sh` fails if `FROM items` appears
|
||||||
|
outside an allowlist of modules. With writes funnelled and 43 reads in one file
|
||||||
|
the allowlist is short enough to mean something — unlike `check:boundary`, which
|
||||||
|
had to pattern-match literals. It will not catch a missed join *inside* the
|
||||||
|
repository; it will catch a new query appearing in a random command file, which
|
||||||
|
is the realistic drift.
|
||||||
|
|
||||||
|
### Downloads — shared files, per-user grants
|
||||||
|
|
||||||
|
The file layout already assumes sharing: paths are content-derived
|
||||||
|
(`{base}/{series}/{S01E02 - Name}`,
|
||||||
|
[download/mod.rs:1052](../../src-tauri/src/commands/download/mod.rs#L1052)) while
|
||||||
|
rows are keyed `UNIQUE(item_id, user_id)` — so two profiles downloading the same
|
||||||
|
episode already aim at one path and clobber each other. Formalising:
|
||||||
|
|
||||||
|
- A second profile requesting an already-downloaded item inserts a **grant**. No
|
||||||
|
bytes transferred; immediately available.
|
||||||
|
- `offline_is_available` joins through grants instead of counting rows per item.
|
||||||
|
- `download_cancel` ([download/mod.rs:1300](../../src-tauri/src/commands/download/mod.rs#L1300))
|
||||||
|
drops the caller's grant and unlinks the file **only when the last grant goes**.
|
||||||
|
It currently deletes unconditionally, which under sharing would yank a file from
|
||||||
|
under another profile.
|
||||||
|
- Budget is naturally shared: the file is counted once. Eviction picks files with
|
||||||
|
no recent access across *any* grant.
|
||||||
|
- A grant is not an entitlement. On unlock while online, grants for items the
|
||||||
|
profile can no longer see are dropped, alongside the visibility re-derivation.
|
||||||
|
|
||||||
|
### Device ID
|
||||||
|
|
||||||
|
[device_get_id](../../src-tauri/src/commands/device.rs#L27) mints one UUID per
|
||||||
|
installation, sent as `DeviceId` on every request
|
||||||
|
([client.rs:60](../../src-tauri/src/jellyfin/client.rs#L60)). Jellyfin uses it to
|
||||||
|
identify a *session* — the Dashboard → Devices row, and the target the
|
||||||
|
remote-control feature casts to.
|
||||||
|
|
||||||
|
**Decision pending an empirical test** (see Open questions). Shipping default is a
|
||||||
|
per-profile derived ID, `uuid5(device_uuid, user_id)`, which makes each family
|
||||||
|
member a distinct device entry so playback history attributes cleanly and the
|
||||||
|
sessions list can tell "this TV, Dad" from "this TV, Kid". If the test shows
|
||||||
|
Jellyfin tolerates a shared ID *and* the merged view is preferred, one line
|
||||||
|
changes.
|
||||||
|
|
||||||
|
### Switch orchestration
|
||||||
|
|
||||||
|
`profiles_switch` is **not** `auth_logout`. Logout invalidates the token
|
||||||
|
server-side; a switch must leave the outgoing profile able to come back with one
|
||||||
|
tap. Ordering, in Rust, as a state machine over a `ProfileSession` so it is
|
||||||
|
unit-testable without a player or a server:
|
||||||
|
|
||||||
|
1. Pause playback and tear down the queue (the queue cannot outlive its owner —
|
||||||
|
a straggler would report the outgoing profile's episode against the incoming one).
|
||||||
|
2. Drain or park `sync_queue` for the outgoing user.
|
||||||
|
3. Stop the session poller; unregister MPRIS / MediaSession metadata.
|
||||||
|
4. Destroy the repository handle.
|
||||||
|
5. Flip `users.is_active`.
|
||||||
|
6. Build the new repository, restart the poller, re-derive visibility and grants
|
||||||
|
if online.
|
||||||
|
7. Emit `profile-switched`.
|
||||||
|
|
||||||
|
Two hazards, both already documented in CLAUDE.md and both reached from a new
|
||||||
|
direction here: never call blocking APIs from player event callbacks, and never
|
||||||
|
hold a lock across a `match` scrutinee. Teardown touches every one of those paths
|
||||||
|
at once.
|
||||||
|
|
||||||
|
### Lock state vs. playback
|
||||||
|
|
||||||
|
"Locked" and "who is the active profile" are **different state**. Re-lock (idle
|
||||||
|
timeout, off by default) flips only the first:
|
||||||
|
|
||||||
|
- Audio keeps playing and keeps reporting as the profile that started it.
|
||||||
|
- The lockscreen / MediaSession keeps full transport control over the **existing
|
||||||
|
queue** — play, pause, seek, next, prev. Nothing on the lockscreen browses or
|
||||||
|
starts new content, so [MediaSessionCompat](../architecture/05-platform-backends.md)
|
||||||
|
needs no changes at all.
|
||||||
|
- The locked UI refuses anything reaching past the current queue: browsing,
|
||||||
|
search, new playback, downloads, settings, switching profile without the PIN.
|
||||||
|
|
||||||
|
Two rules keep it coherent:
|
||||||
|
|
||||||
|
- **Never re-lock while something is playing.** The idle timer starts when
|
||||||
|
playback stops, not when the UI goes quiet. This removes almost all of the
|
||||||
|
conflict on its own.
|
||||||
|
- **Unlocking to a *different* profile stops playback.** Unlocking to the same
|
||||||
|
profile leaves everything running.
|
||||||
|
|
||||||
|
The timer lives in Rust beside the player state machine: it needs authoritative
|
||||||
|
playback state, and a frontend timer dies with the webview on Android.
|
||||||
|
|
||||||
|
### Startup
|
||||||
|
|
||||||
|
The picker appears only when the last-used profile has a PIN, **or** more than one
|
||||||
|
profile exists and "ask who's watching" is on. Otherwise startup resumes the last
|
||||||
|
account exactly as [auth_initialize](../../src-tauri/src/commands/auth.rs#L19)
|
||||||
|
does today. One account, no PIN → the user never sees any of this.
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
Names match the Rust fns; top-level params auto-convert to camelCase.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
profiles_list() -> Vec<Profile>
|
||||||
|
profiles_startup_target() -> StartupTarget // Resume{user_id} | Picker
|
||||||
|
profiles_unlock(user_id: String, pin: Option<String>) -> UnlockOutcome
|
||||||
|
profiles_unlock_with_password(user_id: String, password: String) -> UnlockOutcome
|
||||||
|
profiles_add(username: String, password: String, pin: Option<String>) -> Profile
|
||||||
|
profiles_set_pin(user_id: String, current_pin: Option<String>, new_pin: Option<String>)
|
||||||
|
profiles_remove(user_id: String, forget_downloads: bool)
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Serialize, Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Profile {
|
||||||
|
pub user_id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub avatar_tag: Option<String>,
|
||||||
|
pub unlock_method: UnlockMethod,
|
||||||
|
pub last_used_at: Option<String>,
|
||||||
|
pub is_active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Type)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum UnlockMethod { None, Pin }
|
||||||
|
|
||||||
|
#[derive(Serialize, Type)]
|
||||||
|
#[serde(tag = "type", rename_all = "camelCase")]
|
||||||
|
pub enum UnlockOutcome {
|
||||||
|
Ok { user_id: String },
|
||||||
|
WrongPin { attempts_remaining: u32 },
|
||||||
|
LockedOut { until: String },
|
||||||
|
NeedsPassword,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Event: `profile-switched` (kebab-case), payload `{ userId }`.
|
||||||
|
|
||||||
|
`profiles_add` takes no server URL — it authenticates against the *current*
|
||||||
|
server. That is the same-server constraint, enforced in Rust rather than by
|
||||||
|
omitting a form field.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Multiple servers. The schema already supports it (`users.server_id`); only the
|
||||||
|
flow is constrained. Not a schema change to undo later.
|
||||||
|
- Per-profile content restriction. That is Jellyfin's, server-side.
|
||||||
|
- Profile avatars uploaded locally — use the server's `avatar_tag`.
|
||||||
|
- Biometric unlock.
|
||||||
|
- Idle re-lock is specified above but ships **off by default** and last.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] One account with no PIN: startup, playback and downloads are byte-identical to today.
|
||||||
|
- [ ] A second profile can be added with a password, against the current server only.
|
||||||
|
- [ ] A PIN-less profile switches in one tap; a PIN profile requires the PIN.
|
||||||
|
- [ ] Wrong PIN decrements attempts, then locks out with a stated window; the counter survives an app restart.
|
||||||
|
- [ ] "Use password instead" signs in and offers to set a new PIN.
|
||||||
|
- [ ] Switching does **not** invalidate the outgoing profile's token — switching back needs no password.
|
||||||
|
- [ ] A child profile does not see cached items or downloads belonging to another profile, online or offline.
|
||||||
|
- [ ] Two profiles requesting the same item produce one file and two grants; removing one grant keeps the file.
|
||||||
|
- [ ] Upgrading an existing install shows the same library it showed before (backfill works).
|
||||||
|
- [ ] Playback survives an idle re-lock; lockscreen transport still works; unlocking to a different profile stops it.
|
||||||
|
- [ ] `bun run check`, `bun run test`, `bun run format:check`, `bun run lint` pass.
|
||||||
|
- [ ] `cargo fmt` clean, `cargo clippy -D warnings` clean, `bun run test:rust` passes.
|
||||||
|
- [ ] `bun run check:boundary` and the new `check:cache-scope` pass.
|
||||||
|
- [ ] New code carries `// TRACES:` comments; `bun run traces:validate` passes.
|
||||||
|
- [ ] `bindings.ts` regenerated.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**Rust**
|
||||||
|
- PIN: correct/incorrect/lockout/expiry-of-lockout; counter persists across a
|
||||||
|
service restart; a cleared PIN removes the row.
|
||||||
|
- Switch orchestration as a pure state machine over `ProfileSession` — assert the
|
||||||
|
teardown *ordering*, no player or server needed. This is the part that would
|
||||||
|
otherwise only ever be hand-tested.
|
||||||
|
- Visibility: `save_to_cache` stamps; a second user's read of the same item
|
||||||
|
returns nothing; migration backfill populates the existing user.
|
||||||
|
- Grants: second grant transfers no bytes; cancel with two grants keeps the file;
|
||||||
|
cancel of the last grant unlinks it.
|
||||||
|
- Same-server: `profiles_add` against a different URL is rejected.
|
||||||
|
|
||||||
|
**Frontend**
|
||||||
|
- Picker renders from `profiles_list` with no unlock-method inference of its own.
|
||||||
|
- PIN pad calls `profiles_unlock` and renders each `UnlockOutcome` variant; it
|
||||||
|
never compares a PIN or counts an attempt locally.
|
||||||
|
- `tauriIntegration.test.ts` gains the new commands (camelCase param guard).
|
||||||
|
|
||||||
|
**Manual — the honest gap.** End-to-end multi-user needs two real accounts with
|
||||||
|
differing library permissions on a real server; CI has neither. The state-machine
|
||||||
|
extraction above is what keeps the risky half testable. The rest is a documented
|
||||||
|
manual pass in the release checklist.
|
||||||
|
|
||||||
|
## TRACES
|
||||||
|
|
||||||
|
| Piece | Tag |
|
||||||
|
|-------|-----|
|
||||||
|
| `profiles_*` commands | `UR-082 \| DR-267` |
|
||||||
|
| PIN hash + lockout | `UR-083 \| DR-268` |
|
||||||
|
| Password fallback | `UR-084 \| DR-269` |
|
||||||
|
| Switch orchestration | `UR-082 \| DR-270` |
|
||||||
|
| Visibility stamp/filter | `UR-082 \| DR-271` |
|
||||||
|
| Download grants | `UR-082 \| IR-034, DR-272` |
|
||||||
|
| Per-profile device ID | `UR-082 \| DR-273` |
|
||||||
|
| Startup target | `UR-082 \| DR-274` |
|
||||||
|
| Idle re-lock | `UR-083 \| DR-275` |
|
||||||
|
| Picker + PIN pad UI | `UR-082, UR-083 \| DR-276` |
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
1. **Does authenticating a second user with an in-use `DeviceId` invalidate the
|
||||||
|
first user's token?** Two minutes with two accounts: log in as A, log in as B
|
||||||
|
with the same DeviceId, then call `/Sessions` with A's token. Decides whether
|
||||||
|
the per-profile device ID is a preference or a requirement.
|
||||||
|
2. Should removing a profile default to deleting its exclusive downloads, or
|
||||||
|
keeping them? Spec currently makes it an explicit flag.
|
||||||
|
|
||||||
|
## Notes for the implementer
|
||||||
|
|
||||||
|
- A parallel Claude session may be active in this repo — `git diff` before
|
||||||
|
"repairing" unexpected changes.
|
||||||
|
- `auth_logout` stays exactly as it is. Do not refactor switching through it; the
|
||||||
|
server-side invalidation is the whole reason it is unsuitable.
|
||||||
|
- The docs say the keyring key is `jellytau::{server_id}::{user_id}::access_token`;
|
||||||
|
[credentials.rs:244](../../src-tauri/src/credentials.rs#L244) actually writes
|
||||||
|
`access_token:{user_id}`. Fix the doc, not the code — changing the key format
|
||||||
|
would strand every existing token.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jellytau",
|
"name": "jellytau",
|
||||||
"version": "0.11.5",
|
"version": "0.12.0",
|
||||||
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
|
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
|
||||||
"author": "Duncan Tourolle <duncan@tourolle.paris>",
|
"author": "Duncan Tourolle <duncan@tourolle.paris>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ echo "======================================"
|
|||||||
# Setup environment
|
# Setup environment
|
||||||
echo "Setting up environment..."
|
echo "Setting up environment..."
|
||||||
source "$HOME/.cargo/env.fish" 2>/dev/null || source "$HOME/.cargo/env" || true
|
source "$HOME/.cargo/env.fish" 2>/dev/null || source "$HOME/.cargo/env" || true
|
||||||
export ANDROID_HOME="$HOME/Android/Sdk"
|
export ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}"
|
||||||
export NDK_HOME="$ANDROID_HOME/ndk/$(ls $ANDROID_HOME/ndk 2>/dev/null | head -1)"
|
export NDK_HOME="$ANDROID_HOME/ndk/$(ls $ANDROID_HOME/ndk 2>/dev/null | head -1)"
|
||||||
|
|
||||||
# Check prerequisites
|
# Check prerequisites
|
||||||
|
|||||||
@@ -6,9 +6,26 @@ set -e
|
|||||||
# Source Rust environment
|
# Source Rust environment
|
||||||
source "$HOME/.cargo/env.fish" 2>/dev/null || source "$HOME/.cargo/env" 2>/dev/null || true
|
source "$HOME/.cargo/env.fish" 2>/dev/null || source "$HOME/.cargo/env" 2>/dev/null || true
|
||||||
|
|
||||||
# Set Android environment variables
|
# Set Android environment variables.
|
||||||
export ANDROID_HOME="$HOME/Android/Sdk"
|
#
|
||||||
export NDK_HOME="$ANDROID_HOME/ndk/$(ls "$ANDROID_HOME/ndk" | head -1)"
|
# Defaults, not overrides. A developer's SDK is at ~/Android/Sdk, but CI runs in
|
||||||
|
# the builder image where it lives at /opt/android-sdk and the job sets
|
||||||
|
# ANDROID_HOME accordingly — hardcoding the home-directory path here silently
|
||||||
|
# discarded that and the build died with "Android SDK not found" a minute in.
|
||||||
|
# `test-player-conformance.sh` already had this right; this script did not.
|
||||||
|
export ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}"
|
||||||
|
export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
|
||||||
|
|
||||||
|
if [ ! -d "$ANDROID_HOME/ndk" ]; then
|
||||||
|
echo "❌ No NDK directory at $ANDROID_HOME/ndk" >&2
|
||||||
|
echo " Set ANDROID_HOME to your SDK location, or install the NDK." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Respect an NDK the caller has already picked (CI pins an exact revision via
|
||||||
|
# ANDROID_NDK_HOME); otherwise take whatever is installed.
|
||||||
|
export NDK_HOME="${NDK_HOME:-${ANDROID_NDK_HOME:-$ANDROID_HOME/ndk/$(ls "$ANDROID_HOME/ndk" | head -1)}}"
|
||||||
|
export ANDROID_NDK_HOME="$NDK_HOME"
|
||||||
|
|
||||||
echo "🤖 Building Android APK..."
|
echo "🤖 Building Android APK..."
|
||||||
echo "Android SDK: $ANDROID_HOME"
|
echo "Android SDK: $ANDROID_HOME"
|
||||||
|
|||||||
Generated
+155
-1
@@ -176,12 +176,34 @@ dependencies = [
|
|||||||
"derive_arbitrary",
|
"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]]
|
[[package]]
|
||||||
name = "ascii"
|
name = "ascii"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "assert-json-diff"
|
||||||
|
version = "2.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -360,6 +382,12 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -390,6 +418,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -851,6 +888,24 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deadpool"
|
||||||
|
version = "0.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
|
||||||
|
dependencies = [
|
||||||
|
"deadpool-runtime",
|
||||||
|
"lazy_static",
|
||||||
|
"num_cpus",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deadpool-runtime"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
@@ -913,6 +968,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer",
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1317,6 +1373,21 @@ dependencies = [
|
|||||||
"new_debug_unreachable",
|
"new_debug_unreachable",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-executor",
|
||||||
|
"futures-io",
|
||||||
|
"futures-sink",
|
||||||
|
"futures-task",
|
||||||
|
"futures-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
@@ -1324,6 +1395,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1391,6 +1463,7 @@ version = "0.3.31"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
"futures-macro",
|
"futures-macro",
|
||||||
@@ -1726,6 +1799,25 @@ dependencies = [
|
|||||||
"syn 2.0.112",
|
"syn 2.0.112",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "0.4.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"fnv",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"http",
|
||||||
|
"indexmap 2.12.1",
|
||||||
|
"slab",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -1874,9 +1966,11 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
|
"httpdate",
|
||||||
"itoa",
|
"itoa",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"pin-utils",
|
"pin-utils",
|
||||||
@@ -2181,9 +2275,10 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.11.5"
|
version = "0.12.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
|
"argon2",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -2200,6 +2295,7 @@ dependencies = [
|
|||||||
"libmpv-sys",
|
"libmpv-sys",
|
||||||
"log",
|
"log",
|
||||||
"ndk-context",
|
"ndk-context",
|
||||||
|
"password-hash",
|
||||||
"rand 0.8.7",
|
"rand 0.8.7",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
@@ -2223,6 +2319,7 @@ dependencies = [
|
|||||||
"tokio-util",
|
"tokio-util",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"wiremock",
|
||||||
"zip 2.4.2",
|
"zip 2.4.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2387,6 +2484,12 @@ dependencies = [
|
|||||||
"selectors 0.24.0",
|
"selectors 0.24.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lazy_static"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libappindicator"
|
name = "libappindicator"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2690,6 +2793,16 @@ dependencies = [
|
|||||||
"autocfg",
|
"autocfg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num_cpus"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
|
||||||
|
dependencies = [
|
||||||
|
"hermit-abi",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num_enum"
|
name = "num_enum"
|
||||||
version = "0.7.5"
|
version = "0.7.5"
|
||||||
@@ -3065,6 +3178,17 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"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]]
|
[[package]]
|
||||||
name = "paste"
|
name = "paste"
|
||||||
version = "1.0.15"
|
version = "1.0.15"
|
||||||
@@ -4284,6 +4408,12 @@ dependencies = [
|
|||||||
"stable_deref_trait",
|
"stable_deref_trait",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1_smol"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -5593,6 +5723,7 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
|
"sha1_smol",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6382,6 +6513,29 @@ dependencies = [
|
|||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wiremock"
|
||||||
|
version = "0.6.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
|
||||||
|
dependencies = [
|
||||||
|
"assert-json-diff",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"deadpool",
|
||||||
|
"futures",
|
||||||
|
"http",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"regex",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.46.0"
|
version = "0.46.0"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ name = "jellytau"
|
|||||||
# `player-conformance`, and a second binary makes a bare `cargo run` —
|
# `player-conformance`, and a second binary makes a bare `cargo run` —
|
||||||
# which `tauri dev` issues — ambiguous.
|
# which `tauri dev` issues — ambiguous.
|
||||||
default-run = "jellytau"
|
default-run = "jellytau"
|
||||||
version = "0.11.5"
|
version = "0.12.0"
|
||||||
description = "A cross-platform Jellyfin client"
|
description = "A cross-platform Jellyfin client"
|
||||||
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
|
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -43,7 +43,7 @@ tauri-plugin-opener = "2"
|
|||||||
tauri-plugin-os = "2"
|
tauri-plugin-os = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4", "v5"] }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] }
|
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] }
|
||||||
tokio-util = "0.7"
|
tokio-util = "0.7"
|
||||||
@@ -64,6 +64,12 @@ aes-gcm = "0.10"
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
getrandom = "0.2"
|
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"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
|
|
||||||
@@ -144,6 +150,7 @@ ndk-context = "0.1"
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.24.0"
|
tempfile = "3.24.0"
|
||||||
|
wiremock = "0.6.5"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# Exposes the MediaPlayer conformance suite and the `player-conformance` binary
|
# Exposes the MediaPlayer conformance suite and the `player-conformance` binary
|
||||||
|
|||||||
@@ -92,6 +92,59 @@ That means:
|
|||||||
Follow the right log stream with `./scripts/logcat.sh [debug|release]`
|
Follow the right log stream with `./scripts/logcat.sh [debug|release]`
|
||||||
(defaults to debug).
|
(defaults to debug).
|
||||||
|
|
||||||
|
### Getting a test APK out of CI
|
||||||
|
|
||||||
|
`.gitea/workflows/build-test-apk.yml` produces installable APKs that are **not
|
||||||
|
releases**. Two ways in:
|
||||||
|
|
||||||
|
| Trigger | Result |
|
||||||
|
|---------|--------|
|
||||||
|
| **push to `master`** | Refreshes the rolling **`latest`** pre-release automatically |
|
||||||
|
| **`workflow_dispatch`** | Builds any branch on demand; optionally publishes it as `test-<branch>` |
|
||||||
|
|
||||||
|
#### The rolling `latest` build
|
||||||
|
|
||||||
|
Every push to `master` (bar doc-only ones) rebuilds and replaces the APK on the
|
||||||
|
`latest` pre-release. Both the tag and the asset name are stable, so the
|
||||||
|
download URL never changes:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://gitea.tourolle.paris/dtourolle/jellytau/releases/download/latest/jellytau-latest.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
Send that link to a tester once and it keeps serving the current build. No
|
||||||
|
account needed — release assets are public, unlike Actions artifacts.
|
||||||
|
|
||||||
|
#### What you get, and why it is safe
|
||||||
|
|
||||||
|
Both variants install into the `com.dtourolle.jellytau.debug` slot:
|
||||||
|
|
||||||
|
| Variant | What it is | When |
|
||||||
|
|---------|-----------|------|
|
||||||
|
| `side-by-side-release` (default, and what `latest` always is) | R8-minified, exactly what ships, signed with the **debug** keystore | Almost always — a plain debug build cannot catch R8 stripping JNI-loaded classes, which has broken release APKs here before |
|
||||||
|
| `debug` | Unminified | When you need readable stack traces |
|
||||||
|
|
||||||
|
Three properties make an automatic build on every master push safe:
|
||||||
|
|
||||||
|
- **It cannot replace a real install.** The applicationId is suffixed `.debug`,
|
||||||
|
so it sits beside the store build with its own data. A broken master commit
|
||||||
|
can never take out somebody's working app.
|
||||||
|
- **The production signing key is not involved.** That stays in the tag-driven
|
||||||
|
`build-release.yml`. This workflow needs no secrets beyond the API token.
|
||||||
|
- **The tag is `latest`/`test-*`, never `v*`.** Only `v*` triggers
|
||||||
|
`build-release.yml`. And the desktop updater reads a static `latest.json` from
|
||||||
|
the `updater` branch rather than the release list, so nothing here is offered
|
||||||
|
to existing users.
|
||||||
|
|
||||||
|
**Known gap:** the APK builds in parallel with `build-and-test.yml`, not after
|
||||||
|
it, so `latest` can carry a commit whose tests later fail. Cross-workflow
|
||||||
|
dependencies are not reliably available here, and duplicating the test job would
|
||||||
|
double an already hour-long queue on a single-slot runner. Check the commit's
|
||||||
|
CI status before handing the link to somebody.
|
||||||
|
|
||||||
|
Runs are serialised and `cancel-in-progress` is on, so a burst of pushes to
|
||||||
|
master collapses into one build rather than one per commit.
|
||||||
|
|
||||||
### Key Files
|
### Key Files
|
||||||
|
|
||||||
Player-related Kotlin files:
|
Player-related Kotlin files:
|
||||||
|
|||||||
+137
-3
@@ -19,6 +19,40 @@ pub struct ServerInfo {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
/// Normalized server URL with protocol and no trailing slash
|
/// Normalized server URL with protocol and no trailing slash
|
||||||
pub normalized_url: String,
|
pub normalized_url: String,
|
||||||
|
/// Whether this build can talk to this server, as an **opaque state**.
|
||||||
|
///
|
||||||
|
/// The version string above is informational — for display and for the log.
|
||||||
|
/// This is the judgement, made in Rust, because deciding whether an API
|
||||||
|
/// version is usable is domain reasoning: the frontend must never compare a
|
||||||
|
/// version number, for the same reason it never receives an item-type list.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-286
|
||||||
|
pub compatibility: ServerCompatibility,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The verdict on a server's version.
|
||||||
|
///
|
||||||
|
/// Deliberately three states rather than a boolean. "Unrecognised" is not a
|
||||||
|
/// failure: a server newer than this build resolves forward and works, and
|
||||||
|
/// refusing it would make every JellyTau release expire the moment the server
|
||||||
|
/// upgrades. Only a server below the supported floor is refused, where failure
|
||||||
|
/// is certain rather than merely likely.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-286
|
||||||
|
#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", tag = "type")]
|
||||||
|
pub enum ServerCompatibility {
|
||||||
|
/// A generation this build knows and was tested against.
|
||||||
|
Supported,
|
||||||
|
/// Parsed, but newer than anything this build knows. Treated as the newest
|
||||||
|
/// known generation; everything works, and this exists so the UI *may*
|
||||||
|
/// mention it rather than so it must.
|
||||||
|
NewerThanKnown,
|
||||||
|
/// The version string could not be parsed. Treated as supported — we do not
|
||||||
|
/// refuse a server on the strength of not understanding its version string.
|
||||||
|
UnknownVersion,
|
||||||
|
/// Below the supported floor. This one is a refusal.
|
||||||
|
TooOld { minimum: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// User information
|
/// User information
|
||||||
@@ -166,11 +200,35 @@ impl AuthManager {
|
|||||||
monitor.mark_reachable().await;
|
monitor.mark_reachable().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let capabilities =
|
||||||
|
crate::repository::capabilities::ServerCapabilities::from_reported(
|
||||||
|
&info.version,
|
||||||
|
);
|
||||||
|
let compatibility = if capabilities.is_below_supported_floor() {
|
||||||
|
let (major, minor) =
|
||||||
|
crate::repository::capabilities::MINIMUM_SUPPORTED_MAJOR_MINOR;
|
||||||
|
ServerCompatibility::TooOld {
|
||||||
|
minimum: format!("{major}.{minor}"),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
use crate::repository::capabilities::ServerGeneration;
|
||||||
|
match capabilities.generation {
|
||||||
|
ServerGeneration::Unknown => ServerCompatibility::UnknownVersion,
|
||||||
|
ServerGeneration::V12Plus
|
||||||
|
if capabilities.version.as_ref().is_some_and(|v| v.major > 12) =>
|
||||||
|
{
|
||||||
|
ServerCompatibility::NewerThanKnown
|
||||||
|
}
|
||||||
|
_ => ServerCompatibility::Supported,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Ok(ServerInfo {
|
Ok(ServerInfo {
|
||||||
name: info.server_name,
|
name: info.server_name,
|
||||||
version: info.version,
|
version: info.version,
|
||||||
id: info.id,
|
id: info.id,
|
||||||
normalized_url,
|
normalized_url,
|
||||||
|
compatibility,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -210,7 +268,7 @@ impl AuthManager {
|
|||||||
.client
|
.client
|
||||||
.post(&endpoint)
|
.post(&endpoint)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("X-Emby-Authorization", auth_header)
|
.header("Authorization", auth_header)
|
||||||
.json(&serde_json::json!({
|
.json(&serde_json::json!({
|
||||||
"Username": username,
|
"Username": username,
|
||||||
"Pw": password,
|
"Pw": password,
|
||||||
@@ -286,7 +344,7 @@ impl AuthManager {
|
|||||||
.http_client
|
.http_client
|
||||||
.client
|
.client
|
||||||
.get(&endpoint)
|
.get(&endpoint)
|
||||||
.header("X-Emby-Authorization", auth_header)
|
.header("Authorization", auth_header)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||||
|
|
||||||
@@ -365,7 +423,7 @@ impl AuthManager {
|
|||||||
.http_client
|
.http_client
|
||||||
.client
|
.client
|
||||||
.post(&endpoint)
|
.post(&endpoint)
|
||||||
.header("X-Emby-Authorization", auth_header)
|
.header("Authorization", auth_header)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||||
|
|
||||||
@@ -397,6 +455,82 @@ impl AuthManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod compatibility_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::repository::capabilities::ServerCapabilities;
|
||||||
|
|
||||||
|
/// Mirror of the mapping in `connect_to_server`, so the verdict can be
|
||||||
|
/// asserted without standing up an HTTP server.
|
||||||
|
fn verdict(reported: &str) -> ServerCompatibility {
|
||||||
|
let capabilities = ServerCapabilities::from_reported(reported);
|
||||||
|
if capabilities.is_below_supported_floor() {
|
||||||
|
let (major, minor) = crate::repository::capabilities::MINIMUM_SUPPORTED_MAJOR_MINOR;
|
||||||
|
return ServerCompatibility::TooOld {
|
||||||
|
minimum: format!("{major}.{minor}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
use crate::repository::capabilities::ServerGeneration;
|
||||||
|
match capabilities.generation {
|
||||||
|
ServerGeneration::Unknown => ServerCompatibility::UnknownVersion,
|
||||||
|
ServerGeneration::V12Plus
|
||||||
|
if capabilities.version.as_ref().is_some_and(|v| v.major > 12) =>
|
||||||
|
{
|
||||||
|
ServerCompatibility::NewerThanKnown
|
||||||
|
}
|
||||||
|
_ => ServerCompatibility::Supported,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both live generations are supported outright. 12.0 is the current stable
|
||||||
|
/// and 10.11.x is what this client was built against.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-286
|
||||||
|
#[test]
|
||||||
|
fn both_live_generations_are_supported() {
|
||||||
|
assert_eq!(verdict("10.11.5"), ServerCompatibility::Supported);
|
||||||
|
assert_eq!(verdict("10.11.11"), ServerCompatibility::Supported);
|
||||||
|
assert_eq!(verdict("12.0.0"), ServerCompatibility::Supported);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A server newer than this build is usable, not refused — otherwise every
|
||||||
|
/// release would expire the moment the server upgraded.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-286
|
||||||
|
#[test]
|
||||||
|
fn a_newer_server_is_usable_not_refused() {
|
||||||
|
assert_eq!(verdict("13.0.0"), ServerCompatibility::NewerThanKnown);
|
||||||
|
assert_eq!(verdict("99.1.2"), ServerCompatibility::NewerThanKnown);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An unreadable version is not grounds for refusal.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-286
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_version_is_not_a_refusal() {
|
||||||
|
assert_eq!(
|
||||||
|
verdict("not-a-version"),
|
||||||
|
ServerCompatibility::UnknownVersion
|
||||||
|
);
|
||||||
|
assert_eq!(verdict(""), ServerCompatibility::UnknownVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only a server below the floor is refused, and it says what the floor is
|
||||||
|
/// so the message can name it.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-286
|
||||||
|
#[test]
|
||||||
|
fn only_a_server_below_the_floor_is_refused() {
|
||||||
|
assert_eq!(
|
||||||
|
verdict("10.9.11"),
|
||||||
|
ServerCompatibility::TooOld {
|
||||||
|
minimum: "10.10".to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(verdict("10.10.0"), ServerCompatibility::Supported);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -30,6 +30,39 @@ pub async fn auth_initialize(
|
|||||||
// Try to restore session from storage
|
// Try to restore session from storage
|
||||||
log::info!("[AuthManager] Restoring 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
|
// Use the existing storage_get_active_session function
|
||||||
let active_session =
|
let active_session =
|
||||||
match crate::commands::storage::storage_get_active_session(database, credentials).await {
|
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 playback_reporting;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
pub mod playlist;
|
pub mod playlist;
|
||||||
|
pub mod profiles;
|
||||||
pub mod repository;
|
pub mod repository;
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
@@ -35,6 +36,7 @@ pub use playback_mode::*;
|
|||||||
pub use playback_reporting::*;
|
pub use playback_reporting::*;
|
||||||
pub use player::*;
|
pub use player::*;
|
||||||
pub use playlist::*;
|
pub use playlist::*;
|
||||||
|
pub use profiles::*;
|
||||||
pub use repository::{RepositoryManager, RepositoryManagerWrapper, *};
|
pub use repository::{RepositoryManager, RepositoryManagerWrapper, *};
|
||||||
pub use sessions::*;
|
pub use sessions::*;
|
||||||
pub use storage::*;
|
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(())
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::domain::rank_search_results;
|
use crate::domain::rank_search_results;
|
||||||
use crate::jellyfin::HttpClient;
|
use crate::jellyfin::HttpClient;
|
||||||
|
use crate::repository::capabilities::ServerCapabilities;
|
||||||
use crate::repository::{
|
use crate::repository::{
|
||||||
series_progress, types::*, HybridRepository, MediaRepository, OfflineRepository,
|
series_progress, types::*, HybridRepository, MediaRepository, OfflineRepository,
|
||||||
OnlineRepository, StreamSelection,
|
OnlineRepository, StreamSelection,
|
||||||
@@ -63,6 +64,111 @@ impl RepositoryManager {
|
|||||||
/// Wrapper for Tauri state
|
/// Wrapper for Tauri state
|
||||||
pub struct RepositoryManagerWrapper(pub RepositoryManager);
|
pub struct RepositoryManagerWrapper(pub RepositoryManager);
|
||||||
|
|
||||||
|
/// Read the server's reported version and resolve it into capabilities.
|
||||||
|
///
|
||||||
|
/// Never fails: a server row that is missing, or carries a version this build
|
||||||
|
/// cannot parse, yields the conservative generation rather than an error. A
|
||||||
|
/// client that refused to start because it did not recognise a version string
|
||||||
|
/// would be the exact failure UR-085 exists to remove.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | IR-035, DR-280
|
||||||
|
async fn server_capabilities(
|
||||||
|
db: &Arc<crate::storage::db_service::RusqliteService>,
|
||||||
|
server_id: &str,
|
||||||
|
) -> ServerCapabilities {
|
||||||
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
|
||||||
|
let reported: Option<String> = db
|
||||||
|
.query_one(
|
||||||
|
Query::with_params(
|
||||||
|
"SELECT version FROM servers WHERE id = ?1",
|
||||||
|
vec![QueryParam::String(server_id.to_string())],
|
||||||
|
),
|
||||||
|
|row| row.get::<_, Option<String>>(0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
match reported {
|
||||||
|
Some(version) => ServerCapabilities::from_reported(version.as_str()),
|
||||||
|
None => {
|
||||||
|
debug!("[REPO] No server version recorded for {server_id}; assuming current target");
|
||||||
|
ServerCapabilities::assumed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the cached catalog if the server changed generation since we last looked.
|
||||||
|
///
|
||||||
|
/// Returns whether anything was invalidated, which is what the tests assert on.
|
||||||
|
///
|
||||||
|
/// The first run after this feature ships records the generation and invalidates
|
||||||
|
/// nothing: a NULL column means "never recorded", not "changed". Making the
|
||||||
|
/// absence of information trigger a full re-fetch would charge every existing
|
||||||
|
/// user bandwidth for a server upgrade that has not happened.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-284
|
||||||
|
async fn invalidate_cache_on_generation_change(
|
||||||
|
db: &Arc<crate::storage::db_service::RusqliteService>,
|
||||||
|
server_id: &str,
|
||||||
|
generation: crate::repository::capabilities::ServerGeneration,
|
||||||
|
) -> bool {
|
||||||
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
|
||||||
|
let current = format!("{generation:?}");
|
||||||
|
|
||||||
|
let previous: Option<String> = db
|
||||||
|
.query_one(
|
||||||
|
Query::with_params(
|
||||||
|
"SELECT catalog_generation FROM servers WHERE id = ?1",
|
||||||
|
vec![QueryParam::String(server_id.to_string())],
|
||||||
|
),
|
||||||
|
|row| row.get::<_, Option<String>>(0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
let changed = matches!(previous.as_deref(), Some(prev) if prev != current);
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
warn!(
|
||||||
|
"[REPO] Server generation changed ({:?} -> {}); dropping the cached catalog so it \
|
||||||
|
is re-fetched under the new generation's shapes",
|
||||||
|
previous, current
|
||||||
|
);
|
||||||
|
if let Err(e) = db
|
||||||
|
.execute(Query::with_params(
|
||||||
|
"UPDATE items SET synced_at = NULL WHERE server_id = ?1",
|
||||||
|
vec![QueryParam::String(server_id.to_string())],
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
// Not fatal: stale-but-parseable rows are better than refusing to
|
||||||
|
// start, and the next successful sync overwrites them anyway.
|
||||||
|
error!("[REPO] Failed to invalidate cached catalog: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if previous.as_deref() != Some(current.as_str()) {
|
||||||
|
if let Err(e) = db
|
||||||
|
.execute(Query::with_params(
|
||||||
|
"UPDATE servers SET catalog_generation = ?1 WHERE id = ?2",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(current),
|
||||||
|
QueryParam::String(server_id.to_string()),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
error!("[REPO] Failed to record server generation: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a new repository instance
|
/// Create a new repository instance
|
||||||
/// Returns a handle (UUID) for accessing the repository
|
/// Returns a handle (UUID) for accessing the repository
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -100,17 +206,6 @@ pub async fn repository_create(
|
|||||||
monitor.reporter()
|
monitor.reporter()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create online repository wired to connectivity reporting
|
|
||||||
debug!("[REPO] Creating online repository...");
|
|
||||||
let online = OnlineRepository::new(
|
|
||||||
Arc::new(http_client),
|
|
||||||
server_url,
|
|
||||||
user_id.clone(),
|
|
||||||
access_token,
|
|
||||||
)
|
|
||||||
.with_connectivity(connectivity_reporter);
|
|
||||||
debug!("[REPO] Online repository created");
|
|
||||||
|
|
||||||
// Create offline repository with async-safe database service
|
// Create offline repository with async-safe database service
|
||||||
debug!("[REPO] Creating database service...");
|
debug!("[REPO] Creating database service...");
|
||||||
let db_service = {
|
let db_service = {
|
||||||
@@ -123,6 +218,37 @@ pub async fn repository_create(
|
|||||||
}; // Lock is released here
|
}; // Lock is released here
|
||||||
debug!("[REPO] Database service created");
|
debug!("[REPO] Database service created");
|
||||||
|
|
||||||
|
// Resolve what this server can do, from the version it reported at connect.
|
||||||
|
// `AuthManager::connect_to_server` already parsed it and `storage` already
|
||||||
|
// persisted it, so this costs one indexed read and no extra round trip.
|
||||||
|
//
|
||||||
|
// A missing or unreadable version is not an error: `from_reported` treats it
|
||||||
|
// as the older generation, whose request shapes also work on the newer one.
|
||||||
|
//
|
||||||
|
// TRACES: UR-085 | IR-035, DR-280
|
||||||
|
let capabilities = server_capabilities(&db_service, &server_id).await;
|
||||||
|
info!(
|
||||||
|
"[REPO] Server generation: {:?} (reported {:?})",
|
||||||
|
capabilities.generation,
|
||||||
|
capabilities.version.as_ref().map(|v| v.raw.as_str())
|
||||||
|
);
|
||||||
|
|
||||||
|
// A server upgraded underneath us means the cached catalog was parsed under
|
||||||
|
// a different generation's assumptions. TRACES: UR-085 | DR-284
|
||||||
|
invalidate_cache_on_generation_change(&db_service, &server_id, capabilities.generation).await;
|
||||||
|
|
||||||
|
// Create online repository wired to connectivity reporting
|
||||||
|
debug!("[REPO] Creating online repository...");
|
||||||
|
let online = OnlineRepository::new(
|
||||||
|
Arc::new(http_client),
|
||||||
|
server_url,
|
||||||
|
user_id.clone(),
|
||||||
|
access_token,
|
||||||
|
)
|
||||||
|
.with_connectivity(connectivity_reporter)
|
||||||
|
.with_capabilities(capabilities);
|
||||||
|
debug!("[REPO] Online repository created");
|
||||||
|
|
||||||
debug!("[REPO] Creating offline repository...");
|
debug!("[REPO] Creating offline repository...");
|
||||||
let offline = OfflineRepository::new(db_service, server_id, user_id);
|
let offline = OfflineRepository::new(db_service, server_id, user_id);
|
||||||
debug!("[REPO] Offline repository created");
|
debug!("[REPO] Offline repository created");
|
||||||
@@ -1216,3 +1342,97 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod generation_change_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::repository::capabilities::ServerGeneration;
|
||||||
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
|
||||||
|
|
||||||
|
async fn db_with_server() -> Arc<RusqliteService> {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().expect("in-memory db");
|
||||||
|
for (_, sql) in crate::storage::schema::MIGRATIONS {
|
||||||
|
conn.execute_batch(sql).expect("migration");
|
||||||
|
}
|
||||||
|
let db = Arc::new(RusqliteService::new(Arc::new(std::sync::Mutex::new(conn))));
|
||||||
|
db.execute(Query::with_params(
|
||||||
|
"INSERT INTO servers (id, name, url, version) VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
vec![
|
||||||
|
QueryParam::String("srv-1".into()),
|
||||||
|
QueryParam::String("Home".into()),
|
||||||
|
QueryParam::String("https://example.test".into()),
|
||||||
|
QueryParam::String("10.11.5".into()),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("seed server");
|
||||||
|
db
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn recorded(db: &Arc<RusqliteService>) -> Option<String> {
|
||||||
|
db.query_one(
|
||||||
|
Query::new("SELECT catalog_generation FROM servers WHERE id = 'srv-1'"),
|
||||||
|
|row| row.get::<_, Option<String>>(0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first look records the generation and invalidates nothing. A NULL
|
||||||
|
/// column means "never recorded", not "changed" — treating it as a change
|
||||||
|
/// would charge every existing user a full re-fetch on upgrade.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-284
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_first_look_records_without_invalidating() {
|
||||||
|
let db = db_with_server().await;
|
||||||
|
let invalidated =
|
||||||
|
invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V10_11).await;
|
||||||
|
|
||||||
|
assert!(!invalidated, "a first sighting is not a change");
|
||||||
|
assert_eq!(recorded(&db).await.as_deref(), Some("V10_11"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seeing the same generation again is not a change either.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-284
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_unchanged_generation_does_not_invalidate() {
|
||||||
|
let db = db_with_server().await;
|
||||||
|
invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V10_11).await;
|
||||||
|
let invalidated =
|
||||||
|
invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V10_11).await;
|
||||||
|
|
||||||
|
assert!(!invalidated);
|
||||||
|
assert_eq!(recorded(&db).await.as_deref(), Some("V10_11"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An actual upgrade drops the cached catalog and records the new
|
||||||
|
/// generation, so the next browse re-fetches under the new shapes.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-284
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_real_upgrade_invalidates_and_records() {
|
||||||
|
let db = db_with_server().await;
|
||||||
|
invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V10_11).await;
|
||||||
|
|
||||||
|
let invalidated =
|
||||||
|
invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V12Plus).await;
|
||||||
|
|
||||||
|
assert!(invalidated, "10.11 -> 12.x is a generation change");
|
||||||
|
assert_eq!(recorded(&db).await.as_deref(), Some("V12Plus"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A server row that is missing entirely must not panic or invalidate.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-284
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_unknown_server_is_harmless() {
|
||||||
|
let db = db_with_server().await;
|
||||||
|
let invalidated =
|
||||||
|
invalidate_cache_on_generation_change(&db, "no-such-server", ServerGeneration::V12Plus)
|
||||||
|
.await;
|
||||||
|
assert!(!invalidated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+296
-50
@@ -4,8 +4,19 @@
|
|||||||
//! - Primary: System keyring (Secret Service on Linux, Keychain on macOS)
|
//! - Primary: System keyring (Secret Service on Linux, Keychain on macOS)
|
||||||
//! - Fallback: AES-256-GCM encrypted file when keyring unavailable
|
//! - Fallback: AES-256-GCM encrypted file when keyring unavailable
|
||||||
//!
|
//!
|
||||||
//! The fallback is less secure as the encryption key is derived from machine
|
//! The fallback is **obfuscation at rest, not a secret**: its key sits in a file
|
||||||
//! identifiers, but provides functionality on headless systems.
|
//! beside the ciphertext, so anyone who can read one can read the other. It
|
||||||
|
//! exists so headless systems keep working, and the keyring remains the only
|
||||||
|
//! place a token is actually protected.
|
||||||
|
//!
|
||||||
|
//! The key used to be *derived* from the hostname, `$USER` and a hardcoded salt.
|
||||||
|
//! That was no more secret — those are readable by anyone who can read the file
|
||||||
|
//! — and it was unstable: renaming the machine, or launching from a context
|
||||||
|
//! where `$USER` is unset, changed the key and made every stored token
|
||||||
|
//! undecryptable. `load_credentials_file` treats a failed decrypt as "no stored
|
||||||
|
//! credentials", so that surfaced as being silently signed out rather than as an
|
||||||
|
//! error. The key is now random and persisted, and the old derivation is kept
|
||||||
|
//! only to migrate a file written before this change.
|
||||||
//!
|
//!
|
||||||
//! TRACES: UR-012 | IR-014
|
//! TRACES: UR-012 | IR-014
|
||||||
|
|
||||||
@@ -25,6 +36,119 @@ const SERVICE_NAME: &str = "com.dtourolle.jellytau";
|
|||||||
|
|
||||||
const CREDENTIALS_FILENAME: &str = "credentials.enc";
|
const CREDENTIALS_FILENAME: &str = "credentials.enc";
|
||||||
|
|
||||||
|
/// Key file for the encrypted-file fallback, beside the credentials it opens.
|
||||||
|
const KEY_FILENAME: &str = "credentials.key";
|
||||||
|
|
||||||
|
/// Load the fallback encryption key, creating it on first use.
|
||||||
|
///
|
||||||
|
/// Random rather than derived. A derived key was no more secret — its inputs
|
||||||
|
/// (hostname, `$USER`, a hardcoded salt) are readable by anyone who can read
|
||||||
|
/// the ciphertext — and it silently changed when the machine was renamed or
|
||||||
|
/// `$USER` was unset, which read to the user as being signed out for no reason.
|
||||||
|
///
|
||||||
|
/// If the key cannot be persisted the process still gets a usable key for this
|
||||||
|
/// run; credentials written under it simply will not be readable next launch,
|
||||||
|
/// which is the same outcome as today and better than refusing to store a token.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-012 | IR-014 | UT-014
|
||||||
|
fn load_or_create_key(path: &std::path::Path) -> [u8; 32] {
|
||||||
|
if let Ok(existing) = fs::read(path) {
|
||||||
|
if existing.len() == 32 {
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
key.copy_from_slice(&existing);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
warn!(
|
||||||
|
"Fallback key at {:?} is {} bytes, not 32; replacing it. Credentials \
|
||||||
|
written under the old key will need signing in again.",
|
||||||
|
path,
|
||||||
|
existing.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
if getrandom::getrandom(&mut key).is_err() {
|
||||||
|
warn!("No system randomness for the fallback key; deriving one for this run");
|
||||||
|
return CredentialStore::derive_legacy_encryption_key();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
match fs::write(path, key) {
|
||||||
|
Ok(()) => restrict_to_owner(path),
|
||||||
|
Err(e) => warn!(
|
||||||
|
"Could not persist the fallback key at {:?} ({}); credentials stored \
|
||||||
|
this run will not be readable next launch",
|
||||||
|
path, e
|
||||||
|
),
|
||||||
|
}
|
||||||
|
key
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make a key file owner-readable only. Best effort — a filesystem without
|
||||||
|
/// Unix permissions is not a reason to fail.
|
||||||
|
fn restrict_to_owner(path: &std::path::Path) {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
if let Err(e) = fs::set_permissions(path, fs::Permissions::from_mode(0o600)) {
|
||||||
|
warn!("Could not restrict permissions on {:?}: {}", path, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let _ = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt `encrypted` with `key`.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-012 | IR-014 | UT-014
|
||||||
|
fn decrypt_with(key: &[u8; 32], encrypted: &str) -> Result<String, CredentialError> {
|
||||||
|
let combined = BASE64
|
||||||
|
.decode(encrypted)
|
||||||
|
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||||
|
|
||||||
|
if combined.len() < 12 {
|
||||||
|
return Err(CredentialError::Encryption(
|
||||||
|
"Invalid encrypted data".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (nonce_bytes, ciphertext) = combined.split_at(12);
|
||||||
|
let nonce = Nonce::from_slice(nonce_bytes);
|
||||||
|
|
||||||
|
let cipher =
|
||||||
|
Aes256Gcm::new_from_slice(key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||||
|
|
||||||
|
let plaintext = cipher
|
||||||
|
.decrypt(nonce, ciphertext)
|
||||||
|
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||||
|
|
||||||
|
String::from_utf8(plaintext).map_err(|e| CredentialError::Encryption(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt `plaintext` with `key`, prepending a fresh random nonce.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-012 | IR-014 | UT-014
|
||||||
|
fn encrypt_with(key: &[u8; 32], plaintext: &str) -> Result<String, CredentialError> {
|
||||||
|
let cipher =
|
||||||
|
Aes256Gcm::new_from_slice(key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut nonce_bytes = [0u8; 12];
|
||||||
|
getrandom::getrandom(&mut nonce_bytes)
|
||||||
|
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
|
let ciphertext = cipher
|
||||||
|
.encrypt(nonce, plaintext.as_bytes())
|
||||||
|
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut combined = nonce_bytes.to_vec();
|
||||||
|
combined.extend(ciphertext);
|
||||||
|
|
||||||
|
Ok(BASE64.encode(&combined))
|
||||||
|
}
|
||||||
|
|
||||||
/// Result of a credential storage operation
|
/// Result of a credential storage operation
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum CredentialResult {
|
pub enum CredentialResult {
|
||||||
@@ -66,15 +190,21 @@ pub struct CredentialStore {
|
|||||||
using_keyring: bool,
|
using_keyring: bool,
|
||||||
/// Path to the encrypted credentials file (fallback)
|
/// Path to the encrypted credentials file (fallback)
|
||||||
credentials_path: PathBuf,
|
credentials_path: PathBuf,
|
||||||
/// Encryption key for file fallback (derived from machine ID)
|
/// Encryption key for the file fallback. Random and persisted, so it does
|
||||||
|
/// not change when the machine is renamed.
|
||||||
encryption_key: [u8; 32],
|
encryption_key: [u8; 32],
|
||||||
|
/// The pre-existing derivation, retained only to read a file written before
|
||||||
|
/// the key was persisted. Anything decrypted with it is rewritten under
|
||||||
|
/// `encryption_key`.
|
||||||
|
legacy_key: [u8; 32],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CredentialStore {
|
impl CredentialStore {
|
||||||
/// Create a new credential store, detecting the best available backend
|
/// Create a new credential store, detecting the best available backend
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let credentials_path = Self::get_credentials_path();
|
let credentials_path = Self::get_credentials_path();
|
||||||
let encryption_key = Self::derive_encryption_key();
|
let encryption_key = load_or_create_key(&Self::get_key_path());
|
||||||
|
let legacy_key = Self::derive_legacy_encryption_key();
|
||||||
|
|
||||||
// Test if keyring is available by trying a dummy operation
|
// Test if keyring is available by trying a dummy operation
|
||||||
let using_keyring = Self::test_keyring_available();
|
let using_keyring = Self::test_keyring_available();
|
||||||
@@ -93,6 +223,7 @@ impl CredentialStore {
|
|||||||
using_keyring,
|
using_keyring,
|
||||||
credentials_path,
|
credentials_path,
|
||||||
encryption_key,
|
encryption_key,
|
||||||
|
legacy_key,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,6 +537,16 @@ impl CredentialStore {
|
|||||||
|
|
||||||
// --- Encrypted file backend ---
|
// --- Encrypted file backend ---
|
||||||
|
|
||||||
|
/// Where the fallback key lives: beside the credentials file, so the two
|
||||||
|
/// travel together and a restore that brings one brings the other.
|
||||||
|
fn get_key_path() -> PathBuf {
|
||||||
|
if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") {
|
||||||
|
proj_dirs.data_dir().join(KEY_FILENAME)
|
||||||
|
} else {
|
||||||
|
PathBuf::from(KEY_FILENAME)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn get_credentials_path() -> PathBuf {
|
fn get_credentials_path() -> PathBuf {
|
||||||
if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") {
|
if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") {
|
||||||
proj_dirs.data_dir().join(CREDENTIALS_FILENAME)
|
proj_dirs.data_dir().join(CREDENTIALS_FILENAME)
|
||||||
@@ -414,7 +555,14 @@ impl CredentialStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn derive_encryption_key() -> [u8; 32] {
|
/// The key derivation used before keys were persisted.
|
||||||
|
///
|
||||||
|
/// Kept **only** so a credentials file written by an older build can still
|
||||||
|
/// be read once and rewritten under the persisted key. Never used to
|
||||||
|
/// encrypt. See the module docs for why it was replaced.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-012 | IR-014
|
||||||
|
fn derive_legacy_encryption_key() -> [u8; 32] {
|
||||||
// Derive a key from machine-specific identifiers
|
// Derive a key from machine-specific identifiers
|
||||||
// This is less secure than a true keyring but provides some protection
|
// This is less secure than a true keyring but provides some protection
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
@@ -490,7 +638,7 @@ impl CredentialStore {
|
|||||||
return Ok(serde_json::json!({}));
|
return Ok(serde_json::json!({}));
|
||||||
}
|
}
|
||||||
|
|
||||||
let decrypted = match self.decrypt(&encrypted_data) {
|
let (decrypted, from_legacy_key) = match self.decrypt_migrating(&encrypted_data) {
|
||||||
Ok(decrypted) => decrypted,
|
Ok(decrypted) => decrypted,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(
|
warn!(
|
||||||
@@ -505,7 +653,19 @@ impl CredentialStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match serde_json::from_str(&decrypted) {
|
match serde_json::from_str(&decrypted) {
|
||||||
Ok(value) => Ok(value),
|
Ok(value) => {
|
||||||
|
// Rewrite under the persisted key so the legacy derivation is
|
||||||
|
// never needed again.
|
||||||
|
if from_legacy_key {
|
||||||
|
if let Err(e) = self.save_credentials_file(&value) {
|
||||||
|
warn!(
|
||||||
|
"Could not rewrite credentials under the persisted key: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(
|
warn!(
|
||||||
"Credentials file at {:?} decrypted to invalid JSON ({}); \
|
"Credentials file at {:?} decrypted to invalid JSON ({}); \
|
||||||
@@ -531,48 +691,27 @@ impl CredentialStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn encrypt(&self, plaintext: &str) -> Result<String, CredentialError> {
|
fn encrypt(&self, plaintext: &str) -> Result<String, CredentialError> {
|
||||||
let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
|
encrypt_with(&self.encryption_key, plaintext)
|
||||||
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
|
||||||
|
|
||||||
// Generate a random nonce
|
|
||||||
let mut nonce_bytes = [0u8; 12];
|
|
||||||
getrandom::getrandom(&mut nonce_bytes)
|
|
||||||
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
|
||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
||||||
|
|
||||||
let ciphertext = cipher
|
|
||||||
.encrypt(nonce, plaintext.as_bytes())
|
|
||||||
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
|
||||||
|
|
||||||
// Prepend nonce to ciphertext and encode as base64
|
|
||||||
let mut combined = nonce_bytes.to_vec();
|
|
||||||
combined.extend(ciphertext);
|
|
||||||
|
|
||||||
Ok(BASE64.encode(&combined))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decrypt(&self, encrypted: &str) -> Result<String, CredentialError> {
|
/// Decrypt with the current key, falling back to the legacy derivation.
|
||||||
let combined = BASE64
|
///
|
||||||
.decode(encrypted)
|
/// Returns the plaintext and whether the legacy key was what opened it, so
|
||||||
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
/// the caller can rewrite the file under the current key and stop depending
|
||||||
|
/// on a derivation that changes when the machine is renamed.
|
||||||
if combined.len() < 12 {
|
///
|
||||||
return Err(CredentialError::Encryption(
|
/// TRACES: UR-012 | IR-014 | UT-014
|
||||||
"Invalid encrypted data".to_string(),
|
fn decrypt_migrating(&self, encrypted: &str) -> Result<(String, bool), CredentialError> {
|
||||||
));
|
match decrypt_with(&self.encryption_key, encrypted) {
|
||||||
|
Ok(plaintext) => Ok((plaintext, false)),
|
||||||
|
Err(current_err) => match decrypt_with(&self.legacy_key, encrypted) {
|
||||||
|
Ok(plaintext) => {
|
||||||
|
info!("Credentials were written under the legacy derived key; rewriting them");
|
||||||
|
Ok((plaintext, true))
|
||||||
|
}
|
||||||
|
Err(_) => Err(current_err),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
let (nonce_bytes, ciphertext) = combined.split_at(12);
|
|
||||||
let nonce = Nonce::from_slice(nonce_bytes);
|
|
||||||
|
|
||||||
let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
|
|
||||||
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
|
||||||
|
|
||||||
let plaintext = cipher
|
|
||||||
.decrypt(nonce, ciphertext)
|
|
||||||
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
|
||||||
|
|
||||||
String::from_utf8(plaintext).map_err(|e| CredentialError::Encryption(e.to_string()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn save_to_file(&self, user_id: &str, token: &str) -> Result<(), CredentialError> {
|
fn save_to_file(&self, user_id: &str, token: &str) -> Result<(), CredentialError> {
|
||||||
@@ -884,6 +1023,103 @@ pub use android_keystore::{
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
||||||
|
/// The fallback key must be the same on every launch.
|
||||||
|
///
|
||||||
|
/// It used to be derived from the hostname, `$USER` and a static salt.
|
||||||
|
/// Renaming the machine — or launching from a context where `$USER` is
|
||||||
|
/// unset, such as a systemd user service — changed the key, and
|
||||||
|
/// `load_credentials_file` reports a failed decrypt as "no stored
|
||||||
|
/// credentials". The user was silently signed out with nothing to explain it.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-012 | IR-014 | UT-014
|
||||||
|
#[test]
|
||||||
|
fn the_fallback_key_is_stable_across_processes() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("jellytau-key-{}", std::process::id()));
|
||||||
|
let path = dir.join("credentials.key");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
let first = load_or_create_key(&path);
|
||||||
|
let second = load_or_create_key(&path);
|
||||||
|
assert_eq!(first, second, "the key must not change between launches");
|
||||||
|
assert_ne!(first, [0u8; 32], "the key must be real randomness");
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two installs must not share a key.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-012 | IR-014 | UT-014
|
||||||
|
#[test]
|
||||||
|
fn separate_installs_get_separate_keys() {
|
||||||
|
let base = std::env::temp_dir().join(format!("jellytau-keys-{}", std::process::id()));
|
||||||
|
let a = load_or_create_key(&base.join("a").join("credentials.key"));
|
||||||
|
let b = load_or_create_key(&base.join("b").join("credentials.key"));
|
||||||
|
assert_ne!(a, b);
|
||||||
|
let _ = fs::remove_dir_all(&base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A credentials file written under the old derived key must still open.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-012 | IR-014 | UT-014
|
||||||
|
#[test]
|
||||||
|
fn credentials_written_under_the_legacy_key_still_decrypt() {
|
||||||
|
let legacy = CredentialStore::derive_legacy_encryption_key();
|
||||||
|
let mut persisted = [0u8; 32];
|
||||||
|
getrandom::getrandom(&mut persisted).unwrap();
|
||||||
|
assert_ne!(legacy, persisted);
|
||||||
|
|
||||||
|
let blob = encrypt_with(&legacy, r#"{"user-1":"token-abc"}"#).unwrap();
|
||||||
|
|
||||||
|
let store = CredentialStore {
|
||||||
|
using_keyring: false,
|
||||||
|
credentials_path: PathBuf::from("/nonexistent/credentials.enc"),
|
||||||
|
encryption_key: persisted,
|
||||||
|
legacy_key: legacy,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (plaintext, migrated) = store
|
||||||
|
.decrypt_migrating(&blob)
|
||||||
|
.expect("a file written under the legacy key must still be readable");
|
||||||
|
assert_eq!(plaintext, r#"{"user-1":"token-abc"}"#);
|
||||||
|
assert!(migrated, "the caller must know to rewrite it");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current key is tried first and needs no migration.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-012 | IR-014 | UT-014
|
||||||
|
#[test]
|
||||||
|
fn credentials_under_the_current_key_are_not_flagged_for_migration() {
|
||||||
|
let mut persisted = [0u8; 32];
|
||||||
|
getrandom::getrandom(&mut persisted).unwrap();
|
||||||
|
let blob = encrypt_with(&persisted, "hello").unwrap();
|
||||||
|
|
||||||
|
let store = CredentialStore {
|
||||||
|
using_keyring: false,
|
||||||
|
credentials_path: PathBuf::from("/nonexistent/credentials.enc"),
|
||||||
|
encryption_key: persisted,
|
||||||
|
legacy_key: [7u8; 32],
|
||||||
|
};
|
||||||
|
|
||||||
|
let (plaintext, migrated) = store.decrypt_migrating(&blob).unwrap();
|
||||||
|
assert_eq!(plaintext, "hello");
|
||||||
|
assert!(!migrated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A blob under neither key fails rather than returning something wrong.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-012 | IR-014 | UT-014
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_blob_is_an_error() {
|
||||||
|
let blob = encrypt_with(&[1u8; 32], "secret").unwrap();
|
||||||
|
let store = CredentialStore {
|
||||||
|
using_keyring: false,
|
||||||
|
credentials_path: PathBuf::from("/nonexistent/credentials.enc"),
|
||||||
|
encryption_key: [2u8; 32],
|
||||||
|
legacy_key: [3u8; 32],
|
||||||
|
};
|
||||||
|
assert!(store.decrypt_migrating(&blob).is_err());
|
||||||
|
}
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Build a store pinned to the encrypted-file backend with an explicit key,
|
/// Build a store pinned to the encrypted-file backend with an explicit key,
|
||||||
@@ -894,6 +1130,9 @@ mod tests {
|
|||||||
using_keyring: false,
|
using_keyring: false,
|
||||||
credentials_path,
|
credentials_path,
|
||||||
encryption_key,
|
encryption_key,
|
||||||
|
// A distinct legacy key, so "same file, different machine key" stays
|
||||||
|
// undecryptable rather than being opened by the migration path.
|
||||||
|
legacy_key: [0xABu8; 32],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -959,15 +1198,22 @@ mod tests {
|
|||||||
let plaintext = "test-access-token-12345";
|
let plaintext = "test-access-token-12345";
|
||||||
|
|
||||||
let encrypted = store.encrypt(plaintext).unwrap();
|
let encrypted = store.encrypt(plaintext).unwrap();
|
||||||
let decrypted = store.decrypt(&encrypted).unwrap();
|
let (decrypted, _) = store.decrypt_migrating(&encrypted).unwrap();
|
||||||
|
|
||||||
assert_eq!(plaintext, decrypted);
|
assert_eq!(plaintext, decrypted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The legacy derivation must stay deterministic *within a machine*, or the
|
||||||
|
/// one-time migration of an old credentials file cannot read it.
|
||||||
|
///
|
||||||
|
/// Its instability *across* machine states is exactly why it no longer
|
||||||
|
/// encrypts anything — see `the_fallback_key_is_stable_across_processes`.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-012 | IR-014 | UT-014
|
||||||
#[test]
|
#[test]
|
||||||
fn test_derive_encryption_key_is_deterministic() {
|
fn test_legacy_derivation_is_deterministic_for_migration() {
|
||||||
let key1 = CredentialStore::derive_encryption_key();
|
let key1 = CredentialStore::derive_legacy_encryption_key();
|
||||||
let key2 = CredentialStore::derive_encryption_key();
|
let key2 = CredentialStore::derive_legacy_encryption_key();
|
||||||
assert_eq!(key1, key2);
|
assert_eq!(key1, key2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,6 +183,16 @@ impl DownloadWorker {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
|
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
|
||||||
|
|
||||||
|
// A media file is never legitimately empty, and completing one is worse
|
||||||
|
// than failing: the row goes `completed`, the item shows as available
|
||||||
|
// offline, and playback then stalls on a file with nothing in it. A
|
||||||
|
// server that answered 200 with no body — an error page, a transcode
|
||||||
|
// that produced nothing — used to land here. Treat it as the network
|
||||||
|
// failure it is so the retry budget applies and the `.part` is kept.
|
||||||
|
if let Some(reason) = rejects_as_empty(downloaded) {
|
||||||
|
return Err(DownloadError::Network(reason.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
// Move from .part to final location
|
// Move from .part to final location
|
||||||
fs::rename(&temp_path, &task.target_path)
|
fs::rename(&temp_path, &task.target_path)
|
||||||
.await
|
.await
|
||||||
@@ -229,6 +239,23 @@ pub fn resume_offset(existing_bytes: u64, status: u16) -> u64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Why a finished transfer must not be accepted, if it must not be.
|
||||||
|
///
|
||||||
|
/// A media file is never legitimately empty, and *completing* an empty one is
|
||||||
|
/// worse than failing: the row goes `completed`, the item shows as available
|
||||||
|
/// offline, and playback later stalls on a file with nothing in it. A server
|
||||||
|
/// that answered 200 with no body — an error page, a transcode that produced
|
||||||
|
/// nothing — used to land exactly there.
|
||||||
|
///
|
||||||
|
/// Reported as a network error so the existing retry budget applies and the
|
||||||
|
/// `.part` file is kept for a resume.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-019 | DR-168 | UT-168
|
||||||
|
pub fn rejects_as_empty(downloaded: u64) -> Option<&'static str> {
|
||||||
|
(downloaded == 0)
|
||||||
|
.then_some("server sent an empty body; refusing to complete a zero-byte download")
|
||||||
|
}
|
||||||
|
|
||||||
/// The partial-download sidecar for `target`.
|
/// The partial-download sidecar for `target`.
|
||||||
///
|
///
|
||||||
/// **Appends** `.part` rather than replacing the extension. The worker used
|
/// **Appends** `.part` rather than replacing the extension. The worker used
|
||||||
@@ -305,6 +332,23 @@ impl std::error::Error for DownloadError {}
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// A transfer that produced no bytes must never be marked complete.
|
||||||
|
///
|
||||||
|
/// Completing it publishes an empty file as playable offline; the media
|
||||||
|
/// server then answers a request for it with a 416 and the item simply
|
||||||
|
/// never starts, with nothing in the UI explaining why.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-019 | DR-168 | UT-168
|
||||||
|
#[test]
|
||||||
|
fn test_a_zero_byte_transfer_is_rejected_rather_than_completed() {
|
||||||
|
assert!(
|
||||||
|
rejects_as_empty(0).is_some(),
|
||||||
|
"a zero-byte download must not be completed"
|
||||||
|
);
|
||||||
|
assert!(rejects_as_empty(1).is_none());
|
||||||
|
assert!(rejects_as_empty(4 * 1024 * 1024).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
/// The bitrate-download corruption: a transcode ignores `Range` and answers
|
/// The bitrate-download corruption: a transcode ignores `Range` and answers
|
||||||
/// `200` with the whole stream. Appending that to the bytes already on disk
|
/// `200` with the whole stream. Appending that to the bytes already on disk
|
||||||
/// duplicated them, so every retry grew the file past its real size and left
|
/// duplicated them, so every retry grew the file past its real size and left
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ impl JellyfinClient {
|
|||||||
return "Unknown";
|
return "Unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the X-Emby-Authorization header value
|
/// Build the value for the `Authorization` header (the `MediaBrowser`
|
||||||
|
/// scheme — see `HttpClient::build_auth_header`).
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-287
|
||||||
fn get_auth_header(&self) -> String {
|
fn get_auth_header(&self) -> String {
|
||||||
format!(
|
format!(
|
||||||
"MediaBrowser Client=\"{}\", Version=\"{}\", Device=\"{}\", DeviceId=\"{}\", Token=\"{}\"",
|
"MediaBrowser Client=\"{}\", Version=\"{}\", Device=\"{}\", DeviceId=\"{}\", Token=\"{}\"",
|
||||||
@@ -75,7 +78,7 @@ impl JellyfinClient {
|
|||||||
let response = self
|
let response = self
|
||||||
.http_client
|
.http_client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.header("X-Emby-Authorization", self.get_auth_header())
|
.header("Authorization", self.get_auth_header())
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -155,7 +158,7 @@ impl JellyfinClient {
|
|||||||
.http_client
|
.http_client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("X-Emby-Authorization", self.get_auth_header())
|
.header("Authorization", self.get_auth_header())
|
||||||
.json(body)
|
.json(body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -293,7 +296,7 @@ impl JellyfinClient {
|
|||||||
let response = self
|
let response = self
|
||||||
.http_client
|
.http_client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.header("X-Emby-Authorization", self.get_auth_header())
|
.header("Authorization", self.get_auth_header())
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -360,7 +363,7 @@ impl JellyfinClient {
|
|||||||
let response = self
|
let response = self
|
||||||
.http_client
|
.http_client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.header("X-Emby-Authorization", self.get_auth_header())
|
.header("Authorization", self.get_auth_header())
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Network request failed: {}", e))?;
|
.map_err(|e| format!("Network request failed: {}", e))?;
|
||||||
@@ -503,7 +506,7 @@ impl JellyfinClient {
|
|||||||
let response = self
|
let response = self
|
||||||
.http_client
|
.http_client
|
||||||
.delete(&url)
|
.delete(&url)
|
||||||
.header("X-Emby-Authorization", self.get_auth_header())
|
.header("Authorization", self.get_auth_header())
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Network request failed: {}", e))?;
|
.map_err(|e| format!("Network request failed: {}", e))?;
|
||||||
|
|||||||
@@ -56,6 +56,27 @@ impl HttpClient {
|
|||||||
Ok(Self { client, config })
|
Ok(Self { client, config })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A client that will also talk plain HTTP, for tests only.
|
||||||
|
///
|
||||||
|
/// `new` sets `https_only(true)` and that must stay: it is what stops a
|
||||||
|
/// downgrade putting a session token on the wire in clear. `wiremock` serves
|
||||||
|
/// plain HTTP on loopback, so the alternative to this constructor is either
|
||||||
|
/// weakening the real one or not testing the repository against a server at
|
||||||
|
/// all — and the latter is what DR-281 exists to end.
|
||||||
|
///
|
||||||
|
/// `#[cfg(test)]` so it cannot reach a shipped binary.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-281
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn new_allowing_plaintext_for_tests(config: HttpConfig) -> Result<Self, String> {
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(config.timeout)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||||
|
|
||||||
|
Ok(Self { client, config })
|
||||||
|
}
|
||||||
|
|
||||||
/// Get device name based on platform
|
/// Get device name based on platform
|
||||||
fn get_device_name() -> &'static str {
|
fn get_device_name() -> &'static str {
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
@@ -78,7 +99,16 @@ impl HttpClient {
|
|||||||
return "Unknown";
|
return "Unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the X-Emby-Authorization header value
|
/// Build the value for the `Authorization` header.
|
||||||
|
///
|
||||||
|
/// The `MediaBrowser` scheme, which is the non-deprecated one: Jellyfin 12.0
|
||||||
|
/// disables `X-Emby-Authorization` (and the `Emby` scheme, `X-Emby-Token`
|
||||||
|
/// and `X-MediaBrowser-Token`) by default, and a migration turns it off on
|
||||||
|
/// upgraded servers too. `Authorization: MediaBrowser …` is ungated on both
|
||||||
|
/// 10.11.x and 12.x, so this is one value for both generations rather than a
|
||||||
|
/// capability branch.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-287
|
||||||
pub fn build_auth_header(access_token: Option<&str>, device_id: &str) -> String {
|
pub fn build_auth_header(access_token: Option<&str>, device_id: &str) -> String {
|
||||||
let mut parts = vec![
|
let mut parts = vec![
|
||||||
format!("MediaBrowser Client=\"{}\"", APP_NAME),
|
format!("MediaBrowser Client=\"{}\"", APP_NAME),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ mod media_server;
|
|||||||
mod playback_mode;
|
mod playback_mode;
|
||||||
mod playback_reporting;
|
mod playback_reporting;
|
||||||
mod player;
|
mod player;
|
||||||
|
mod profiles;
|
||||||
mod repository;
|
mod repository;
|
||||||
mod session_poller;
|
mod session_poller;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
@@ -194,6 +195,15 @@ use commands::{
|
|||||||
playlist_move_item,
|
playlist_move_item,
|
||||||
playlist_remove_items,
|
playlist_remove_items,
|
||||||
playlist_rename,
|
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 session control commands
|
||||||
remote_play_on_session,
|
remote_play_on_session,
|
||||||
remote_send_command,
|
remote_send_command,
|
||||||
@@ -1036,6 +1046,15 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
|||||||
playlist_rename,
|
playlist_rename,
|
||||||
playlist_get_items,
|
playlist_get_items,
|
||||||
playlist_add_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_remove_items,
|
||||||
playlist_move_item,
|
playlist_move_item,
|
||||||
// Diagnostics commands
|
// Diagnostics commands
|
||||||
|
|||||||
@@ -326,8 +326,12 @@ impl Span {
|
|||||||
///
|
///
|
||||||
/// TRACES: UR-071 | DR-137 | UT-127
|
/// TRACES: UR-071 | DR-137 | UT-127
|
||||||
pub fn span_for(range: Option<&str>, len: u64) -> Option<Span> {
|
pub fn span_for(range: Option<&str>, len: u64) -> Option<Span> {
|
||||||
|
// A zero-length file has no byte to serve. `end` is inclusive, so the
|
||||||
|
// shortest span this type can express is one byte — returning one for an
|
||||||
|
// empty file declared `Content-Length: 1` and then streamed nothing, which
|
||||||
|
// Chromium's media loader waits on forever. 416 says so honestly instead.
|
||||||
if len == 0 {
|
if len == 0 {
|
||||||
return Some(Span { start: 0, end: 0 });
|
return None;
|
||||||
}
|
}
|
||||||
let last = len - 1;
|
let last = len - 1;
|
||||||
let first_chunk = Span {
|
let first_chunk = Span {
|
||||||
@@ -442,6 +446,42 @@ fn content_type(path: &Path, head: &[u8]) -> &'static str {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// An empty file must not be answered with a span that promises a byte.
|
||||||
|
///
|
||||||
|
/// `Span::len()` is `end + 1 - start`, so the `Span { start: 0, end: 0 }`
|
||||||
|
/// that a zero-length file used to produce reported a length of **one**.
|
||||||
|
/// The response then declared `Content-Length: 1` and streamed nothing,
|
||||||
|
/// which Chromium's media loader waits on forever — reaching the user as a
|
||||||
|
/// downloaded item that never starts. A zero-byte file has no satisfiable
|
||||||
|
/// range, so 416 is the honest answer.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-137 | UT-127
|
||||||
|
#[test]
|
||||||
|
fn test_span_for_an_empty_file_is_unsatisfiable() {
|
||||||
|
assert!(
|
||||||
|
span_for(None, 0).is_none(),
|
||||||
|
"a zero-length file has no byte to serve"
|
||||||
|
);
|
||||||
|
assert!(span_for(Some("bytes=0-"), 0).is_none());
|
||||||
|
assert!(span_for(Some("bytes=0-100"), 0).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whatever a span says, its length must match the bytes that follow it.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-071 | DR-137 | UT-127
|
||||||
|
#[test]
|
||||||
|
fn test_span_len_never_exceeds_the_file() {
|
||||||
|
for len in [0u64, 1, 2, 4095, CHUNK_LEN, CHUNK_LEN + 1] {
|
||||||
|
if let Some(span) = span_for(None, len) {
|
||||||
|
assert!(
|
||||||
|
span.len() <= len,
|
||||||
|
"span for a {len}-byte file claims {} bytes",
|
||||||
|
span.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The whole point: a request with no `Range` must still come back bounded.
|
/// The whole point: a request with no `Range` must still come back bounded.
|
||||||
/// That is the case Tauri's asset protocol answers with the entire file —
|
/// That is the case Tauri's asset protocol answers with the entire file —
|
||||||
/// the read Chromium abandoned after 31s.
|
/// the read Chromium abandoned after 31s.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
//! This module provides a `PlayerBackend` implementation using Android's ExoPlayer
|
//! This module provides a `PlayerBackend` implementation using Android's ExoPlayer
|
||||||
//! through JNI calls to Kotlin code.
|
//! through JNI calls to Kotlin code.
|
||||||
|
|
||||||
|
use super::jni_guard::jni_guard;
|
||||||
use crate::utils::lock::MutexSafe;
|
use crate::utils::lock::MutexSafe;
|
||||||
use log::debug;
|
use log::debug;
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
@@ -677,6 +678,9 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
position: jdouble,
|
position: jdouble,
|
||||||
duration: jdouble,
|
duration: jdouble,
|
||||||
) {
|
) {
|
||||||
|
jni_guard(
|
||||||
|
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPositionUpdate",
|
||||||
|
|| {
|
||||||
// Debug: Log every 10th update to avoid spam
|
// Debug: Log every 10th update to avoid spam
|
||||||
static mut UPDATE_COUNTER: u32 = 0;
|
static mut UPDATE_COUNTER: u32 = 0;
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -713,6 +717,8 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
// playing, but guard on the stored state anyway. Mirrors the MPV backend's
|
// playing, but guard on the stored state anyway. Mirrors the MPV backend's
|
||||||
// progress loop; both share the same EventThrottler (every 30s per item).
|
// progress loop; both share the same EventThrottler (every 30s per item).
|
||||||
report_android_progress(position);
|
report_android_progress(position);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Report throttled playback progress to Jellyfin from the Android position
|
/// Report throttled playback progress to Jellyfin from the Android position
|
||||||
@@ -774,8 +780,17 @@ fn report_android_progress(position: f64) {
|
|||||||
handle.spawn(spawn_report());
|
handle.spawn(spawn_report());
|
||||||
} else {
|
} else {
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
// Not `unwrap()`: this runs on a JNI thread, and `Runtime::new()`
|
||||||
rt.block_on(spawn_report());
|
// fails under the fd exhaustion and thread-spawn refusal Android
|
||||||
|
// subjects a media app to. The panic used to unwind out of the
|
||||||
|
// `extern "system"` caller and abort the process — losing one
|
||||||
|
// progress report is recoverable, losing the app is not.
|
||||||
|
match tokio::runtime::Runtime::new() {
|
||||||
|
Ok(rt) => rt.block_on(spawn_report()),
|
||||||
|
Err(e) => log::error!(
|
||||||
|
"[Android] No runtime available to report progress; dropping it: {e}"
|
||||||
|
),
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,6 +805,9 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
state: JString,
|
state: JString,
|
||||||
media_id: JString,
|
media_id: JString,
|
||||||
) {
|
) {
|
||||||
|
jni_guard(
|
||||||
|
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnStateChanged",
|
||||||
|
|| {
|
||||||
let state_str: String = env.get_string(&state).map(|s| s.into()).unwrap_or_default();
|
let state_str: String = env.get_string(&state).map(|s| s.into()).unwrap_or_default();
|
||||||
|
|
||||||
let media_id_opt: Option<String> = if media_id.is_null() {
|
let media_id_opt: Option<String> = if media_id.is_null() {
|
||||||
@@ -835,6 +853,8 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
media_id: media_id_opt,
|
media_id: media_id_opt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called when media has finished loading.
|
/// Called when media has finished loading.
|
||||||
@@ -844,6 +864,9 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
_class: JClass,
|
_class: JClass,
|
||||||
duration: jdouble,
|
duration: jdouble,
|
||||||
) {
|
) {
|
||||||
|
jni_guard(
|
||||||
|
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnMediaLoaded",
|
||||||
|
|| {
|
||||||
if let Some(state) = SHARED_STATE.get() {
|
if let Some(state) = SHARED_STATE.get() {
|
||||||
let mut state = state.lock_safe();
|
let mut state = state.lock_safe();
|
||||||
state.duration = Some(duration);
|
state.duration = Some(duration);
|
||||||
@@ -853,6 +876,8 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||||
emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
|
emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called when playback reaches the end.
|
/// Called when playback reaches the end.
|
||||||
@@ -861,6 +886,9 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
_env: JNIEnv,
|
_env: JNIEnv,
|
||||||
_class: JClass,
|
_class: JClass,
|
||||||
) {
|
) {
|
||||||
|
jni_guard(
|
||||||
|
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPlaybackEnded",
|
||||||
|
|| {
|
||||||
log::info!("[ExoPlayer] Playback ended - processing autoplay decision");
|
log::info!("[ExoPlayer] Playback ended - processing autoplay decision");
|
||||||
|
|
||||||
// Get player controller and handle autoplay decision
|
// Get player controller and handle autoplay decision
|
||||||
@@ -912,13 +940,19 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
queue.items().len()
|
queue.items().len()
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
log::debug!("[Autoplay] Queue state after next(): {}", queue_info);
|
log::debug!(
|
||||||
|
"[Autoplay] Queue state after next(): {}",
|
||||||
|
queue_info
|
||||||
|
);
|
||||||
|
|
||||||
// Emit queue changed event so frontend updates UI with new current track
|
// Emit queue changed event so frontend updates UI with new current track
|
||||||
ctrl.emit_queue_changed();
|
ctrl.emit_queue_changed();
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("[Autoplay] Failed to advance to next track: {}", e);
|
log::error!(
|
||||||
|
"[Autoplay] Failed to advance to next track: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
// Emit PlaybackEnded event on error
|
// Emit PlaybackEnded event on error
|
||||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||||
@@ -999,6 +1033,8 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called when buffering state changes.
|
/// Called when buffering state changes.
|
||||||
@@ -1008,11 +1044,16 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
_class: JClass,
|
_class: JClass,
|
||||||
percent: jint,
|
percent: jint,
|
||||||
) {
|
) {
|
||||||
|
jni_guard(
|
||||||
|
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnBuffering",
|
||||||
|
|| {
|
||||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||||
emitter.emit(PlayerStatusEvent::Buffering {
|
emitter.emit(PlayerStatusEvent::Buffering {
|
||||||
percent: percent as u8,
|
percent: percent as u8,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called when a playback error occurs.
|
/// Called when a playback error occurs.
|
||||||
@@ -1023,6 +1064,9 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
message: JString,
|
message: JString,
|
||||||
recoverable: jboolean,
|
recoverable: jboolean,
|
||||||
) {
|
) {
|
||||||
|
jni_guard(
|
||||||
|
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnError",
|
||||||
|
|| {
|
||||||
let message_str: String = env
|
let message_str: String = env
|
||||||
.get_string(&message)
|
.get_string(&message)
|
||||||
.map(|s| s.into())
|
.map(|s| s.into())
|
||||||
@@ -1084,6 +1128,8 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
recoverable,
|
recoverable,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called when volume changes.
|
/// Called when volume changes.
|
||||||
@@ -1094,6 +1140,9 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
volume: jfloat,
|
volume: jfloat,
|
||||||
muted: jboolean,
|
muted: jboolean,
|
||||||
) {
|
) {
|
||||||
|
jni_guard(
|
||||||
|
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnVolumeChanged",
|
||||||
|
|| {
|
||||||
if let Some(state) = SHARED_STATE.get() {
|
if let Some(state) = SHARED_STATE.get() {
|
||||||
state.lock_safe().volume = volume;
|
state.lock_safe().volume = volume;
|
||||||
}
|
}
|
||||||
@@ -1104,6 +1153,8 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
muted: muted != 0,
|
muted: muted != 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// JNI callback for MediaSession commands from JellyTauPlaybackService
|
// JNI callback for MediaSession commands from JellyTauPlaybackService
|
||||||
@@ -1127,6 +1178,9 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackServic
|
|||||||
_class: JClass,
|
_class: JClass,
|
||||||
command: JString,
|
command: JString,
|
||||||
) {
|
) {
|
||||||
|
jni_guard(
|
||||||
|
"Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnMediaCommand",
|
||||||
|
|| {
|
||||||
let command_str: String = env
|
let command_str: String = env
|
||||||
.get_string(&command)
|
.get_string(&command)
|
||||||
.map(|s| s.into())
|
.map(|s| s.into())
|
||||||
@@ -1135,6 +1189,8 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackServic
|
|||||||
if let Some(handler) = MEDIA_COMMAND_HANDLER.get() {
|
if let Some(handler) = MEDIA_COMMAND_HANDLER.get() {
|
||||||
handler.on_command(&command_str);
|
handler.on_command(&command_str);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// JNI callback from JellyTauPlaybackService when volume buttons are pressed in remote mode.
|
/// JNI callback from JellyTauPlaybackService when volume buttons are pressed in remote mode.
|
||||||
@@ -1148,6 +1204,9 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackServic
|
|||||||
command: JString,
|
command: JString,
|
||||||
volume: jint,
|
volume: jint,
|
||||||
) {
|
) {
|
||||||
|
jni_guard(
|
||||||
|
"Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnRemoteVolumeChange",
|
||||||
|
|| {
|
||||||
let command_str: String = env
|
let command_str: String = env
|
||||||
.get_string(&command)
|
.get_string(&command)
|
||||||
.map(|s| s.into())
|
.map(|s| s.into())
|
||||||
@@ -1156,6 +1215,8 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackServic
|
|||||||
if let Some(handler) = REMOTE_VOLUME_HANDLER.get() {
|
if let Some(handler) = REMOTE_VOLUME_HANDLER.get() {
|
||||||
handler.on_remote_volume_change(&command_str, volume as i32);
|
handler.on_remote_volume_change(&command_str, volume as i32);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// JNI callback from Kotlin when codec detection completes.
|
/// JNI callback from Kotlin when codec detection completes.
|
||||||
@@ -1170,6 +1231,9 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Co
|
|||||||
audio_codecs: JString,
|
audio_codecs: JString,
|
||||||
max_audio_channels: jint,
|
max_audio_channels: jint,
|
||||||
) {
|
) {
|
||||||
|
jni_guard(
|
||||||
|
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Companion_nativeOnCodecsDetected",
|
||||||
|
|| {
|
||||||
let video_str: String = env
|
let video_str: String = env
|
||||||
.get_string(&video_codecs)
|
.get_string(&video_codecs)
|
||||||
.map(|s| s.into())
|
.map(|s| s.into())
|
||||||
@@ -1204,6 +1268,8 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Co
|
|||||||
if DETECTED_CODECS.set(codecs).is_err() {
|
if DETECTED_CODECS.set(codecs).is_err() {
|
||||||
log::error!("[CodecDetection] Failed to store codecs - already initialized");
|
log::error!("[CodecDetection] Failed to store codecs - already initialized");
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start the JellyTauPlaybackService if not already running.
|
/// Start the JellyTauPlaybackService if not already running.
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
//! Panic containment for the Android JNI boundary.
|
||||||
|
//!
|
||||||
|
//! Compiled on every platform, unlike `player::android` itself, so the guard and
|
||||||
|
//! the tripwire that enforces its use are unit-tested on the host — the same
|
||||||
|
//! reason `RESUME_BACKOFF_STEP_SECS` lives outside the `cfg(android)` block.
|
||||||
|
|
||||||
|
/// Run the body of a JNI callback with any panic contained.
|
||||||
|
///
|
||||||
|
/// Every `extern "system"` function in this file is called by the JVM on an
|
||||||
|
/// arbitrary thread. A panic that unwinds out of one crosses the FFI boundary,
|
||||||
|
/// which Rust answers by **aborting the process** — the app vanishes with no
|
||||||
|
/// Java exception, no stack trace attributable to it, and no crash report the
|
||||||
|
/// user can send. That is the worst possible failure mode for the callbacks
|
||||||
|
/// that fire four times a second during playback.
|
||||||
|
///
|
||||||
|
/// The panics are real, not theoretical: this file builds a fallback Tokio
|
||||||
|
/// runtime on threads that have none, and `Runtime::new()` fails under the fd
|
||||||
|
/// exhaustion and thread-spawn refusal an Android device puts a media app
|
||||||
|
/// through. Losing one position report is recoverable; losing the process is
|
||||||
|
/// not.
|
||||||
|
///
|
||||||
|
/// A contained panic still leaves whatever it interrupted half-done, so this is
|
||||||
|
/// a backstop, not a licence to panic. `utils::lock` already keeps a poisoned
|
||||||
|
/// mutex from cascading; this keeps the FFI boundary from turning any remaining
|
||||||
|
/// panic into a process kill.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-005 | DR-052
|
||||||
|
///
|
||||||
|
/// Only *called* from `player::android`, which is `cfg(target_os = "android")`,
|
||||||
|
/// so it is dead code on every other target — the same reason
|
||||||
|
/// `RESUME_BACKOFF_STEP_SECS` carries this attribute. It is still compiled and
|
||||||
|
/// tested here on purpose.
|
||||||
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
|
pub(crate) fn jni_guard<F: FnOnce()>(name: &str, body: F) {
|
||||||
|
// AssertUnwindSafe: the shared state behind these callbacks is already
|
||||||
|
// reached through poison-tolerant locks, so a panic cannot hand out a
|
||||||
|
// guard observing a torn value.
|
||||||
|
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)).is_err() {
|
||||||
|
// The panic hook has already logged the payload and location.
|
||||||
|
log::error!("[JNI] Panic in {name} was contained; the callback was dropped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRACES: UR-005 | DR-052 | UT-052
|
||||||
|
#[cfg(test)]
|
||||||
|
mod jni_guard_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The guard must swallow a panic rather than let it reach the JVM.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-005 | DR-052 | UT-052
|
||||||
|
#[test]
|
||||||
|
fn a_panicking_callback_body_does_not_escape_the_guard() {
|
||||||
|
let hook = std::panic::take_hook();
|
||||||
|
std::panic::set_hook(Box::new(|_| {}));
|
||||||
|
jni_guard("test_callback", || panic!("ExoPlayer callback blew up"));
|
||||||
|
std::panic::set_hook(hook);
|
||||||
|
// Reaching here at all is the assertion: without the guard the panic
|
||||||
|
// would unwind out of the `extern "system"` fn and abort the process.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The guard must not disturb a callback that behaves.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-005 | DR-052 | UT-052
|
||||||
|
#[test]
|
||||||
|
fn a_normal_callback_body_still_runs() {
|
||||||
|
let mut ran = false;
|
||||||
|
jni_guard("test_callback", || ran = true);
|
||||||
|
assert!(ran);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Tripwire.** Every JNI entry point must wrap its body in `jni_guard`.
|
||||||
|
///
|
||||||
|
/// A panic crossing the `extern "system"` boundary aborts the process, so a
|
||||||
|
/// twelfth callback added without the guard reintroduces the whole defect.
|
||||||
|
/// Checked against the source because the real boundary needs a JVM to
|
||||||
|
/// exercise — the same tripwire idiom as `check:boundary`.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-005 | DR-052 | UT-052
|
||||||
|
#[test]
|
||||||
|
fn every_jni_entry_point_wraps_its_body_in_the_guard() {
|
||||||
|
let src = include_str!("android/mod.rs");
|
||||||
|
let mut unguarded = Vec::new();
|
||||||
|
|
||||||
|
let mut lines = src.lines().enumerate().peekable();
|
||||||
|
while let Some((_, line)) = lines.next() {
|
||||||
|
if !line.starts_with("pub extern \"system\" fn ") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = line
|
||||||
|
.trim_start_matches("pub extern \"system\" fn ")
|
||||||
|
.trim_end_matches('(')
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Walk to the end of the parameter list, then look at the first
|
||||||
|
// statement of the body.
|
||||||
|
let mut body_start = None;
|
||||||
|
for (n, l) in lines.by_ref() {
|
||||||
|
if l.trim_end().ends_with(") {") || l.trim() == ") {" {
|
||||||
|
body_start = Some(n);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(body_start.is_some(), "could not find the body of {name}");
|
||||||
|
|
||||||
|
match lines.peek() {
|
||||||
|
Some((_, first)) if first.trim_start().starts_with("jni_guard(") => {}
|
||||||
|
other => unguarded.push(format!(
|
||||||
|
"{name} (body starts with {:?})",
|
||||||
|
other.map(|(_, l)| l.trim()).unwrap_or("<eof>")
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
unguarded.is_empty(),
|
||||||
|
"JNI entry points whose body is not wrapped in jni_guard — a panic in \
|
||||||
|
one of these aborts the process:\n {}",
|
||||||
|
unguarded.join("\n ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
//! A declared lock hierarchy for [`PlayerController`], and a tripwire that
|
||||||
|
//! enforces it.
|
||||||
|
//!
|
||||||
|
//! The controller carries seventeen separate mutexes, reached from the MPV event
|
||||||
|
//! loop, JNI callbacks, sleep/autoplay timers, the session poller and every IPC
|
||||||
|
//! command. Nothing about that arrangement prevents two threads taking the same
|
||||||
|
//! two locks in opposite orders, which deadlocks the player outright — and this
|
||||||
|
//! subsystem has already produced one deadlock (a tokio `MutexGuard` held in a
|
||||||
|
//! `match` scrutinee, which stalled the `AdvanceToNext` arm).
|
||||||
|
//!
|
||||||
|
//! Today the code is disciplined: acquisitions are scoped, and `previous()` for
|
||||||
|
//! instance explicitly drops the backend guard before touching the queue. But
|
||||||
|
//! that holds by convention, and convention is not checked. [`LOCK_ORDER`]
|
||||||
|
//! writes the convention down and `every_overlapping_acquisition_respects_the_order`
|
||||||
|
//! fails the build when a change breaks it.
|
||||||
|
//!
|
||||||
|
//! Ordering only matters where one guard is **still held** while another lock is
|
||||||
|
//! taken. Acquiring two locks one after another, each released before the next,
|
||||||
|
//! cannot deadlock — so the analysis looks for overlap, not for mere sequence.
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-005 | DR-052
|
||||||
|
|
||||||
|
// This module is a static analysis of `player/mod.rs` plus the hierarchy it
|
||||||
|
// checks against. Its only caller is its own test module, but `LOCK_ORDER` is
|
||||||
|
// the documentation of record for how these locks nest, so it stays compiled
|
||||||
|
// (and rustdoc'd) rather than hidden behind `cfg(test)`.
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
/// The order in which `PlayerController`'s locks may be nested.
|
||||||
|
///
|
||||||
|
/// A thread already holding one of these may only acquire a lock that appears
|
||||||
|
/// **later** in this list. The order is not arbitrary — it follows the nesting
|
||||||
|
/// the code already relies on:
|
||||||
|
///
|
||||||
|
/// - `repository` and `sleep_timer` are taken by long-running decisions that go
|
||||||
|
/// on to consult playback state, so they sit outermost.
|
||||||
|
/// - `backend` outranks `queue`: "what is playing" is read before "what is
|
||||||
|
/// next", never the reverse.
|
||||||
|
/// - `event_emitter` is last. Emitting is a leaf — notifying the frontend must
|
||||||
|
/// never reach back for more player state.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-005 | DR-052
|
||||||
|
pub const LOCK_ORDER: &[&str] = &[
|
||||||
|
"repository",
|
||||||
|
"sleep_timer",
|
||||||
|
"countdown_cancel",
|
||||||
|
"jellyfin_client",
|
||||||
|
"backend",
|
||||||
|
"queue",
|
||||||
|
"stream_resume",
|
||||||
|
"end_reason",
|
||||||
|
"reported_time",
|
||||||
|
"background_audio_active",
|
||||||
|
"background_audio_base",
|
||||||
|
"html5_playing",
|
||||||
|
"autoplay_settings",
|
||||||
|
"autoplay_episode_count",
|
||||||
|
"reports",
|
||||||
|
"event_emitter",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Rank of `field` in [`LOCK_ORDER`], or `None` if it is not a declared lock.
|
||||||
|
pub fn rank(field: &str) -> Option<usize> {
|
||||||
|
LOCK_ORDER.iter().position(|f| *f == field)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One lock acquired while another is still held.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub struct Overlap {
|
||||||
|
/// The lock already held.
|
||||||
|
pub outer: String,
|
||||||
|
/// The lock acquired underneath it.
|
||||||
|
pub inner: String,
|
||||||
|
/// 1-indexed line of the inner acquisition, for a useful failure message.
|
||||||
|
pub line: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find every place `src` takes a lock while holding another.
|
||||||
|
///
|
||||||
|
/// Deliberately simple and line-based: it tracks `let … = self.FIELD.lock_safe()`
|
||||||
|
/// bindings and looks for a different `self.OTHER.lock_safe()` before the
|
||||||
|
/// binding goes out of scope or is explicitly dropped. A guard that is not bound
|
||||||
|
/// to a name (`*self.flag.lock_safe() = false;`) is released at the end of its
|
||||||
|
/// statement and cannot overlap anything, so it is only ever an *inner*
|
||||||
|
/// acquisition here.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-005 | DR-052 | UT-052
|
||||||
|
pub fn overlapping_acquisitions(src: &str) -> Vec<Overlap> {
|
||||||
|
let lines: Vec<&str> = src.lines().collect();
|
||||||
|
let mut found = Vec::new();
|
||||||
|
|
||||||
|
for (i, line) in lines.iter().enumerate() {
|
||||||
|
let Some((var, field)) = parse_binding(line) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let indent = line.len() - line.trim_start().len();
|
||||||
|
|
||||||
|
for (j, later) in lines.iter().enumerate().skip(i + 1) {
|
||||||
|
if later.contains(&format!("drop({var})")) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let trimmed = later.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Left the block the guard lives in.
|
||||||
|
let later_indent = later.len() - later.trim_start().len();
|
||||||
|
if later_indent < indent && !trimmed.starts_with(['.', ')', '}']) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if trimmed == "}" && later_indent < indent {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(inner) = parse_acquisition(later, field) {
|
||||||
|
found.push(Overlap {
|
||||||
|
outer: field.to_string(),
|
||||||
|
inner,
|
||||||
|
line: j + 1,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `let [mut] name = self.field.lock_safe()` → `(name, field)`.
|
||||||
|
fn parse_binding(line: &str) -> Option<(&str, &str)> {
|
||||||
|
let rest = line.trim_start().strip_prefix("let ")?;
|
||||||
|
let rest = rest.strip_prefix("mut ").unwrap_or(rest);
|
||||||
|
let (name, rest) = rest.split_once(" = self.")?;
|
||||||
|
let (field, _) = rest.split_once(".lock_safe()")?;
|
||||||
|
if name.contains(' ') || field.contains('.') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((name, field))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first `self.other.lock_safe()` on `line` that is not `held`.
|
||||||
|
fn parse_acquisition(line: &str, held: &str) -> Option<String> {
|
||||||
|
let mut search = line;
|
||||||
|
while let Some(at) = search.find("self.") {
|
||||||
|
let after = &search[at + 5..];
|
||||||
|
if let Some((field, _)) = after.split_once(".lock_safe()") {
|
||||||
|
if !field.contains(['.', '(', ' ']) && field != held {
|
||||||
|
return Some(field.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
search = after;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRACES: UR-005 | DR-052 | UT-052
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// **The tripwire.** Every nested acquisition in the controller must follow
|
||||||
|
/// [`LOCK_ORDER`].
|
||||||
|
///
|
||||||
|
/// A violation is a lock-order inversion: two threads taking the same pair
|
||||||
|
/// in opposite orders deadlock the player, and the symptom is a frozen app
|
||||||
|
/// with no error anywhere.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-005 | DR-052 | UT-052
|
||||||
|
#[test]
|
||||||
|
fn every_overlapping_acquisition_respects_the_order() {
|
||||||
|
let src = include_str!("mod.rs");
|
||||||
|
let mut violations = Vec::new();
|
||||||
|
|
||||||
|
for overlap in overlapping_acquisitions(src) {
|
||||||
|
let (Some(outer), Some(inner)) = (rank(&overlap.outer), rank(&overlap.inner)) else {
|
||||||
|
violations.push(format!(
|
||||||
|
"player/mod.rs:{} takes '{}' while holding '{}', and one of them \
|
||||||
|
is not declared in LOCK_ORDER",
|
||||||
|
overlap.line, overlap.inner, overlap.outer
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if outer >= inner {
|
||||||
|
violations.push(format!(
|
||||||
|
"player/mod.rs:{} takes '{}' (rank {inner}) while holding '{}' \
|
||||||
|
(rank {outer}) — an inversion against LOCK_ORDER",
|
||||||
|
overlap.line, overlap.inner, overlap.outer
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
violations.is_empty(),
|
||||||
|
"lock-order inversions in PlayerController:\n {}\n\nEither reorder the \
|
||||||
|
acquisitions or, if the new order is the correct one, change LOCK_ORDER \
|
||||||
|
and re-check every other site.",
|
||||||
|
violations.join("\n ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The analysis must actually see the nesting the controller does today,
|
||||||
|
/// or the tripwire above passes by finding nothing.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-005 | DR-052 | UT-052
|
||||||
|
#[test]
|
||||||
|
fn the_analysis_finds_the_nesting_that_exists() {
|
||||||
|
let found = overlapping_acquisitions(include_str!("mod.rs"));
|
||||||
|
assert!(
|
||||||
|
found.len() >= 5,
|
||||||
|
"expected the controller's known nested acquisitions, found {found:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
found
|
||||||
|
.iter()
|
||||||
|
.any(|o| o.outer == "backend" && o.inner == "queue"),
|
||||||
|
"the backend->queue nesting in state() should be detected: {found:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A guard held across a lock taken in the wrong order must be caught.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-005 | DR-052 | UT-052
|
||||||
|
#[test]
|
||||||
|
fn an_inversion_is_detected() {
|
||||||
|
let src = " fn bad(&self) {\n\
|
||||||
|
\x20 let queue = self.queue.lock_safe();\n\
|
||||||
|
\x20 let b = self.backend.lock_safe();\n\
|
||||||
|
\x20 }\n";
|
||||||
|
let found = overlapping_acquisitions(src);
|
||||||
|
assert_eq!(found.len(), 1, "{found:?}");
|
||||||
|
assert_eq!(found[0].outer, "queue");
|
||||||
|
assert_eq!(found[0].inner, "backend");
|
||||||
|
assert!(rank("queue").unwrap() > rank("backend").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sequential, non-overlapping acquisitions cannot deadlock and must not be
|
||||||
|
/// reported — `previous()` drops the backend guard before taking the queue.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-005 | DR-052 | UT-052
|
||||||
|
#[test]
|
||||||
|
fn a_dropped_guard_is_not_an_overlap() {
|
||||||
|
let src = " fn fine(&self) {\n\
|
||||||
|
\x20 let backend = self.backend.lock_safe();\n\
|
||||||
|
\x20 drop(backend);\n\
|
||||||
|
\x20 let queue = self.queue.lock_safe();\n\
|
||||||
|
\x20 }\n";
|
||||||
|
assert!(overlapping_acquisitions(src).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every declared lock name must be a real field, or the order documents
|
||||||
|
/// something that no longer exists.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-005 | DR-052 | UT-052
|
||||||
|
#[test]
|
||||||
|
fn every_declared_lock_is_a_real_field() {
|
||||||
|
let src = include_str!("mod.rs");
|
||||||
|
let decl = src
|
||||||
|
.split_once("pub struct PlayerController {")
|
||||||
|
.expect("PlayerController struct")
|
||||||
|
.1;
|
||||||
|
let decl = decl.split_once("\n}").expect("end of struct").0;
|
||||||
|
|
||||||
|
for name in LOCK_ORDER {
|
||||||
|
assert!(
|
||||||
|
decl.contains(&format!("{name}:")),
|
||||||
|
"LOCK_ORDER names '{name}', which is not a PlayerController field"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,14 @@ pub mod track_switch;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod mpv_backend_test;
|
mod mpv_backend_test;
|
||||||
|
|
||||||
|
// The declared lock hierarchy for `PlayerController` below, and the tripwire
|
||||||
|
// that enforces it. See the module docs for why seventeen mutexes need one.
|
||||||
|
pub mod lock_order;
|
||||||
|
|
||||||
|
// Panic containment for the JNI boundary. Not gated on the target: the guard
|
||||||
|
// and its tripwire test are exercised on the host, where `android` never builds.
|
||||||
|
pub mod jni_guard;
|
||||||
|
|
||||||
// Platform-specific backends
|
// Platform-specific backends
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub mod android;
|
pub mod android;
|
||||||
@@ -4467,7 +4475,7 @@ mod tests {
|
|||||||
duration: Some(runtime_seconds),
|
duration: Some(runtime_seconds),
|
||||||
source: MediaSource::Remote {
|
source: MediaSource::Remote {
|
||||||
stream_url:
|
stream_url:
|
||||||
"http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=0"
|
"http://s/Audio/ep2/universal?ApiKey=k&AudioStreamIndex=2&StartTimeTicks=0"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
jellyfin_item_id: "ep2".to_string(),
|
jellyfin_item_id: "ep2".to_string(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ pub fn is_truncated_end(position: f64, duration: Option<f64>, tolerance: f64) ->
|
|||||||
/// Resuming re-opens *the stream we were already playing*, so the URL is edited
|
/// Resuming re-opens *the stream we were already playing*, so the URL is edited
|
||||||
/// in place rather than rebuilt from the repository: every other parameter —
|
/// in place rather than rebuilt from the repository: every other parameter —
|
||||||
/// `AudioStreamIndex` (the track the user picked in the video player),
|
/// `AudioStreamIndex` (the track the user picked in the video player),
|
||||||
/// `MediaSourceId`, `api_key` — is carried over untouched, and no network call
|
/// `MediaSourceId`, `ApiKey` — is carried over untouched, and no network call
|
||||||
/// is needed to recover from a network failure.
|
/// is needed to recover from a network failure.
|
||||||
pub fn with_start_time(url: &str, position_seconds: f64) -> String {
|
pub fn with_start_time(url: &str, position_seconds: f64) -> String {
|
||||||
let ticks = (position_seconds.max(0.0) * 10_000_000.0) as i64;
|
let ticks = (position_seconds.max(0.0) * 10_000_000.0) as i64;
|
||||||
@@ -387,22 +387,22 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_with_start_time_replaces_existing_ticks() {
|
fn test_with_start_time_replaces_existing_ticks() {
|
||||||
let url = "http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=1200000000&Container=mp3";
|
let url = "http://s/Audio/ep2/universal?ApiKey=k&AudioStreamIndex=2&StartTimeTicks=1200000000&Container=mp3";
|
||||||
let out = with_start_time(url, 600.0);
|
let out = with_start_time(url, 600.0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
out,
|
out,
|
||||||
"http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=6000000000&Container=mp3"
|
"http://s/Audio/ep2/universal?ApiKey=k&AudioStreamIndex=2&StartTimeTicks=6000000000&Container=mp3"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_with_start_time_appends_when_absent() {
|
fn test_with_start_time_appends_when_absent() {
|
||||||
// The next-episode stream is built without StartTimeTicks.
|
// The next-episode stream is built without StartTimeTicks.
|
||||||
let url = "http://s/Audio/ep3/universal?api_key=k&AudioStreamIndex=0";
|
let url = "http://s/Audio/ep3/universal?ApiKey=k&AudioStreamIndex=0";
|
||||||
let out = with_start_time(url, 90.0);
|
let out = with_start_time(url, 90.0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
out,
|
out,
|
||||||
"http://s/Audio/ep3/universal?api_key=k&AudioStreamIndex=0&StartTimeTicks=900000000"
|
"http://s/Audio/ep3/universal?ApiKey=k&AudioStreamIndex=0&StartTimeTicks=900000000"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
//! What the server on the other end of the wire can actually do.
|
||||||
|
//!
|
||||||
|
//! One `ServerCapabilities` value is resolved per connection, from the version
|
||||||
|
//! the server already reports at `/System/Info/Public`, and every decision that
|
||||||
|
//! depends on the server generation reads a **named flag** from it.
|
||||||
|
//!
|
||||||
|
//! # Why flags and not version comparisons
|
||||||
|
//!
|
||||||
|
//! A `version < N` written at the point of use re-derives a domain fact where it
|
||||||
|
//! is consumed — the same error as a Jellyfin taxonomy in the frontend, and the
|
||||||
|
//! reason `check:boundary` exists. It is also unreadable by its second
|
||||||
|
//! occurrence (`< 11` says nothing about *what* changed), and it cannot express
|
||||||
|
//! a backport, where a behaviour appears in a patch release of an older line.
|
||||||
|
//!
|
||||||
|
//! So the version → flags mapping lives in exactly one function
|
||||||
|
//! ([`ServerCapabilities::for_version`]) and nothing else in the crate compares
|
||||||
|
//! a version number.
|
||||||
|
//!
|
||||||
|
//! # Why an unknown version resolves forward
|
||||||
|
//!
|
||||||
|
//! A server newer than this build resolves to the newest capability set we know
|
||||||
|
//! rather than being refused. Refusing would make every JellyTau release expire
|
||||||
|
//! the moment the server upgrades, which is the failure UR-085 exists to remove.
|
||||||
|
//! Refusal is reserved for a version *below* [`MINIMUM_SUPPORTED_MAJOR_MINOR`],
|
||||||
|
//! where failure is certain rather than merely likely.
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-085 | IR-035, DR-280
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// The oldest server this build will talk to, as `(major, minor)`.
|
||||||
|
///
|
||||||
|
/// This is the current target and not a researched floor: no older server has
|
||||||
|
/// been tested against, so claiming support for one would be a guess. Lower it
|
||||||
|
/// when a real server has been exercised, not before.
|
||||||
|
pub const MINIMUM_SUPPORTED_MAJOR_MINOR: (u32, u32) = (10, 10);
|
||||||
|
|
||||||
|
/// A parsed server version.
|
||||||
|
///
|
||||||
|
/// Jellyfin reports things like `10.11.5`, `10.11.5.0` and occasionally a
|
||||||
|
/// build suffix (`10.11.5-rc1`). Only the leading numeric components are
|
||||||
|
/// meaningful here; anything after them is preserved in `raw` for logging and
|
||||||
|
/// otherwise ignored.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ServerVersion {
|
||||||
|
pub major: u32,
|
||||||
|
pub minor: u32,
|
||||||
|
pub patch: u32,
|
||||||
|
pub raw: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerVersion {
|
||||||
|
/// Parse what `/System/Info/Public` reported.
|
||||||
|
///
|
||||||
|
/// Returns `None` for anything without at least a numeric major, which is
|
||||||
|
/// treated as "unknown" rather than as an error — an unparseable version is
|
||||||
|
/// not a reason to refuse a server that may work perfectly well.
|
||||||
|
pub fn parse(raw: &str) -> Option<Self> {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop at the first character that cannot begin a numeric component, so
|
||||||
|
// `10.11.5-rc1` and `10.11.5+build7` both yield 10.11.5.
|
||||||
|
let numeric_prefix: String = trimmed
|
||||||
|
.chars()
|
||||||
|
.take_while(|c| c.is_ascii_digit() || *c == '.')
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut parts = numeric_prefix.split('.').filter(|p| !p.is_empty());
|
||||||
|
let major = parts.next()?.parse().ok()?;
|
||||||
|
let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
|
||||||
|
let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
major,
|
||||||
|
minor,
|
||||||
|
patch,
|
||||||
|
raw: trimmed.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_below_floor(&self) -> bool {
|
||||||
|
(self.major, self.minor) < MINIMUM_SUPPORTED_MAJOR_MINOR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ServerVersion {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How this build classified the server it is talking to.
|
||||||
|
///
|
||||||
|
/// There are exactly two live cases, and the gap between them is not a typo:
|
||||||
|
/// **Jellyfin 11.0 does not exist and never did.** With 12.0 the project dropped
|
||||||
|
/// the leading `10` from its scheme, so what would have been 10.12.0 shipped as
|
||||||
|
/// `12.0` and the server reports `Version: "12.0.0"`. 12.0 is therefore *one*
|
||||||
|
/// release-branch step from 10.11, not two, and `major == 11` will never occur.
|
||||||
|
///
|
||||||
|
/// Source: <https://jellyfin.org/posts/jellyfin-release-12.0>, which explicitly
|
||||||
|
/// flags version-string parsers as the thing to check before upgrading.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ServerGeneration {
|
||||||
|
/// The 10.x line — `major == 10`. What this client was built against.
|
||||||
|
V10_11,
|
||||||
|
/// The post-rename line — `major >= 12`.
|
||||||
|
V12Plus,
|
||||||
|
/// The server did not report a parseable version. Treated as the older
|
||||||
|
/// generation, which is the conservative choice: its flags are the ones that
|
||||||
|
/// also work on 12.x.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The resolved answer, carried by `OnlineRepository` for the life of a
|
||||||
|
/// connection.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ServerCapabilities {
|
||||||
|
pub version: Option<ServerVersion>,
|
||||||
|
pub generation: ServerGeneration,
|
||||||
|
|
||||||
|
/// Whether item queries go to `/Users/{userId}/Items` (`true`) or to
|
||||||
|
/// `/Items?userId=` (`false`).
|
||||||
|
///
|
||||||
|
/// **`true` for every generation, and deliberately so.** The whole
|
||||||
|
/// `/Users/{userId}/…` family still exists and still works in 12.0 — only
|
||||||
|
/// six routes were removed anywhere, and the only user-scoped one is
|
||||||
|
/// `POST /Users/{userId}/EasyPassword`, which this client never called.
|
||||||
|
///
|
||||||
|
/// What *did* change is policy: the family has carried `[Obsolete]` and been
|
||||||
|
/// hidden from the OpenAPI spec since 10.11.5, and 12.0 states in writing
|
||||||
|
/// that unspecified endpoints "can be removed in any major release without
|
||||||
|
/// warning". The replacements (`/Items?userId=` and friends) already exist
|
||||||
|
/// on 10.11.5, so migrating is a one-generation-compatible change whenever
|
||||||
|
/// it is wanted — which is why the route table carries both shapes even
|
||||||
|
/// though nothing selects the second one yet. See DR-282.
|
||||||
|
pub user_scoped_item_routes: bool,
|
||||||
|
|
||||||
|
/// Whether the server honours the **audio codec** in a submitted
|
||||||
|
/// `DirectPlayProfile`.
|
||||||
|
///
|
||||||
|
/// `false` on 10.11.5: it enforces the profile's container and video codec
|
||||||
|
/// but ignores its audio codec, so it offers direct play for an E-AC-3 track
|
||||||
|
/// the renderer cannot decode and the picture plays in silence. The client
|
||||||
|
/// therefore has to overrule the server's own direct-play offer. See
|
||||||
|
/// `device_profile::audio_forces_transcode` and DR-283.
|
||||||
|
///
|
||||||
|
/// **Still `false` on 12.x, and that is an admission rather than a finding.**
|
||||||
|
/// A source-level diff of 12.0 could not establish whether the underlying
|
||||||
|
/// behaviour changed; it established only that 12.0 *reports* codec
|
||||||
|
/// mismatches in `TranscodeReasons` which 10.11.5 omitted, which is not the
|
||||||
|
/// same claim. Keeping the override on costs a transcode that might not be
|
||||||
|
/// needed; turning it off on a guess costs silent playback. Flip it only
|
||||||
|
/// against a running 12.x server.
|
||||||
|
pub honours_directplay_audio_codec: bool,
|
||||||
|
|
||||||
|
/// Whether a source whose container is a *manifest* (`hls`, `applehttp`,
|
||||||
|
/// `dash`) may be direct-played. 12.0 makes such sources ineligible; on
|
||||||
|
/// 10.11.x they were eligible, which is what this client has assumed.
|
||||||
|
pub supports_manifest_container_direct_play: bool,
|
||||||
|
|
||||||
|
/// Whether asking the image endpoint for a size larger than the stored image
|
||||||
|
/// returns that size. 10.11.x upscaled; 12.0 returns the original instead.
|
||||||
|
/// Governs layout expectation only — a smaller image is never an error.
|
||||||
|
pub image_endpoint_upscales: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerCapabilities {
|
||||||
|
/// The single place a version becomes behaviour. Nothing else in the crate
|
||||||
|
/// compares a version number.
|
||||||
|
pub fn for_version(version: Option<ServerVersion>) -> Self {
|
||||||
|
let generation = match &version {
|
||||||
|
None => ServerGeneration::Unknown,
|
||||||
|
// `major >= 12` and `major == 10` are the two live cases; 11 will
|
||||||
|
// never occur. A hypothetical 11 sorts with the older line, which is
|
||||||
|
// the conservative side.
|
||||||
|
Some(v) if v.major >= 12 => ServerGeneration::V12Plus,
|
||||||
|
Some(_) => ServerGeneration::V10_11,
|
||||||
|
};
|
||||||
|
|
||||||
|
let v12 = generation == ServerGeneration::V12Plus;
|
||||||
|
|
||||||
|
Self {
|
||||||
|
version,
|
||||||
|
generation,
|
||||||
|
// Unchanged across both generations — see each flag's docs. Note the
|
||||||
|
// two genuinely breaking changes 12.0 introduced (the auth spelling
|
||||||
|
// and the `Recursive` default) are fixed by writing the request
|
||||||
|
// correctly for *both*, so neither appears here. A flag is a silent
|
||||||
|
// branch that outlives the reason it was added; keep them for
|
||||||
|
// genuine either/or behaviour only.
|
||||||
|
user_scoped_item_routes: true,
|
||||||
|
honours_directplay_audio_codec: false,
|
||||||
|
supports_manifest_container_direct_play: !v12,
|
||||||
|
image_endpoint_upscales: !v12,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve straight from what the server reported.
|
||||||
|
pub fn from_reported(raw_version: &str) -> Self {
|
||||||
|
Self::for_version(ServerVersion::parse(raw_version))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What this build assumes with no server to ask — the current target.
|
||||||
|
/// Used by offline paths and by tests that do not care.
|
||||||
|
pub fn assumed() -> Self {
|
||||||
|
Self::for_version(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the server is old enough that failure is certain rather than
|
||||||
|
/// likely. An unparseable version is never below the floor: we do not refuse
|
||||||
|
/// a server on the strength of not understanding its version string.
|
||||||
|
pub fn is_below_supported_floor(&self) -> bool {
|
||||||
|
self.version.as_ref().is_some_and(|v| v.is_below_floor())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ServerCapabilities {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::assumed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// TRACES: UR-085 | DR-280
|
||||||
|
#[test]
|
||||||
|
fn parses_the_shapes_a_real_server_reports() {
|
||||||
|
assert_eq!(
|
||||||
|
ServerVersion::parse("10.11.5").unwrap().to_string(),
|
||||||
|
"10.11.5"
|
||||||
|
);
|
||||||
|
// Four components: Jellyfin reports these, the fourth is ignored.
|
||||||
|
assert_eq!(
|
||||||
|
ServerVersion::parse("10.11.5.0").unwrap().to_string(),
|
||||||
|
"10.11.5"
|
||||||
|
);
|
||||||
|
// A pre-release suffix must not defeat parsing.
|
||||||
|
assert_eq!(
|
||||||
|
ServerVersion::parse("10.11.5-rc1").unwrap().to_string(),
|
||||||
|
"10.11.5"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ServerVersion::parse("10.11.5+build7").unwrap().to_string(),
|
||||||
|
"10.11.5"
|
||||||
|
);
|
||||||
|
// Missing components default rather than failing.
|
||||||
|
assert_eq!(ServerVersion::parse("11").unwrap().to_string(), "11.0.0");
|
||||||
|
assert_eq!(
|
||||||
|
ServerVersion::parse(" 10.10 ").unwrap().to_string(),
|
||||||
|
"10.10.0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nonsense is "unknown", never a panic and never a refusal.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-280, DR-286
|
||||||
|
#[test]
|
||||||
|
fn unparseable_versions_are_unknown_not_fatal() {
|
||||||
|
for raw in ["", " ", "not-a-version", "v", "-", "..."] {
|
||||||
|
assert!(
|
||||||
|
ServerVersion::parse(raw).is_none(),
|
||||||
|
"{raw:?} should not parse"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let caps = ServerCapabilities::from_reported("not-a-version");
|
||||||
|
assert_eq!(caps.generation, ServerGeneration::Unknown);
|
||||||
|
assert!(
|
||||||
|
!caps.is_below_supported_floor(),
|
||||||
|
"an unreadable version must not refuse a server that may work"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A server newer than this build keeps working. Refusing it would make
|
||||||
|
/// every release expire the moment the server upgrades.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-286
|
||||||
|
#[test]
|
||||||
|
fn a_newer_than_known_server_resolves_forward() {
|
||||||
|
let newer = ServerCapabilities::from_reported("99.0.0");
|
||||||
|
assert_eq!(newer.generation, ServerGeneration::V12Plus);
|
||||||
|
assert!(!newer.is_below_supported_floor());
|
||||||
|
|
||||||
|
// It resolves to the newest known generation's flags; only the recorded
|
||||||
|
// version differs.
|
||||||
|
let known = ServerCapabilities::from_reported("12.0.0");
|
||||||
|
assert_eq!(
|
||||||
|
newer,
|
||||||
|
ServerCapabilities {
|
||||||
|
version: newer.version.clone(),
|
||||||
|
..known
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The version scheme changed: 12.0 *is* 10.12 renamed, so 11 never occurs
|
||||||
|
/// and a parser must not assume a leading `10.`.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-280
|
||||||
|
#[test]
|
||||||
|
fn the_two_live_generations_are_10_and_12_with_no_11() {
|
||||||
|
assert_eq!(
|
||||||
|
ServerCapabilities::from_reported("10.11.5").generation,
|
||||||
|
ServerGeneration::V10_11
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ServerCapabilities::from_reported("12.0.0").generation,
|
||||||
|
ServerGeneration::V12Plus
|
||||||
|
);
|
||||||
|
// 11 cannot be reported by any real server; if one somehow does, it
|
||||||
|
// sorts with the older line rather than being treated as newer.
|
||||||
|
assert_eq!(
|
||||||
|
ServerCapabilities::from_reported("11.0.0").generation,
|
||||||
|
ServerGeneration::V10_11
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The flags that genuinely differ, and only those.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-283
|
||||||
|
#[test]
|
||||||
|
fn manifest_direct_play_and_upscaling_are_the_flags_that_differ() {
|
||||||
|
let old = ServerCapabilities::from_reported("10.11.5");
|
||||||
|
let new = ServerCapabilities::from_reported("12.0.0");
|
||||||
|
|
||||||
|
assert!(old.supports_manifest_container_direct_play);
|
||||||
|
assert!(!new.supports_manifest_container_direct_play);
|
||||||
|
assert!(old.image_endpoint_upscales);
|
||||||
|
assert!(!new.image_endpoint_upscales);
|
||||||
|
|
||||||
|
// The two breaking changes 12.0 introduced are NOT flags: they are fixed
|
||||||
|
// by writing the request correctly for both generations.
|
||||||
|
assert_eq!(old.user_scoped_item_routes, new.user_scoped_item_routes);
|
||||||
|
assert_eq!(
|
||||||
|
old.honours_directplay_audio_codec, new.honours_directplay_audio_codec,
|
||||||
|
"unestablished against a running 12.x server; must not be flipped on a guess"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nothing may reintroduce an authentication spelling that 12.0 disables by
|
||||||
|
/// default. The header *value* is correct on both generations; only the
|
||||||
|
/// names were deprecated, so this is a structural guard.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-287
|
||||||
|
#[test]
|
||||||
|
fn no_deprecated_auth_spelling_reaches_a_request_builder() {
|
||||||
|
let sources: &[(&str, &str)] = &[
|
||||||
|
("repository/online.rs", include_str!("online.rs")),
|
||||||
|
("jellyfin/client.rs", include_str!("../jellyfin/client.rs")),
|
||||||
|
(
|
||||||
|
"jellyfin/http_client.rs",
|
||||||
|
include_str!("../jellyfin/http_client.rs"),
|
||||||
|
),
|
||||||
|
("auth/mod.rs", include_str!("../auth/mod.rs")),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (name, src) in sources {
|
||||||
|
assert!(
|
||||||
|
!src.contains(r#".header("X-Emby-Authorization""#),
|
||||||
|
"{name}: X-Emby-Authorization is disabled by default on Jellyfin 12.0 \
|
||||||
|
(a migration flips it on upgraded servers too). Use `Authorization` \
|
||||||
|
with the same MediaBrowser value — ungated on both generations."
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!src.contains(r#".header("X-Emby-Token""#)
|
||||||
|
&& !src.contains(r#".header("X-MediaBrowser-Token""#),
|
||||||
|
"{name}: token headers are gated behind EnableLegacyAuthorization on 12.0"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!src.contains("api_key="),
|
||||||
|
"{name}: `api_key` as a query parameter is gated on 12.0. Use `ApiKey`, \
|
||||||
|
ungated on both and what the server itself emits."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: UR-085 | DR-286
|
||||||
|
#[test]
|
||||||
|
fn a_server_below_the_floor_is_refused() {
|
||||||
|
assert!(ServerCapabilities::from_reported("10.9.11").is_below_supported_floor());
|
||||||
|
assert!(ServerCapabilities::from_reported("9.0.0").is_below_supported_floor());
|
||||||
|
assert!(!ServerCapabilities::from_reported("10.10.0").is_below_supported_floor());
|
||||||
|
assert!(!ServerCapabilities::from_reported("10.11.5").is_below_supported_floor());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The documented 10.11.5 behaviour, pinned so that flipping it later is a
|
||||||
|
/// deliberate act with a citation rather than a drive-by edit.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-283
|
||||||
|
#[test]
|
||||||
|
fn the_current_target_does_not_honour_directplay_audio_codec() {
|
||||||
|
let caps = ServerCapabilities::from_reported("10.11.5");
|
||||||
|
assert_eq!(caps.generation, ServerGeneration::V10_11);
|
||||||
|
assert!(
|
||||||
|
!caps.honours_directplay_audio_codec,
|
||||||
|
"10.11.5 ignores a DirectPlayProfile's audio codec; the client must overrule it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No generation may quietly acquire an unverified route change.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-282
|
||||||
|
#[test]
|
||||||
|
fn no_generation_yet_disables_user_scoped_routes() {
|
||||||
|
for raw in ["10.10.0", "10.11.5", "11.0.0", "12.0.0", "99.9.9"] {
|
||||||
|
assert!(
|
||||||
|
ServerCapabilities::from_reported(raw).user_scoped_item_routes,
|
||||||
|
"{raw}: flipping this needs a cited upstream source (DR-282), not a guess"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -434,7 +434,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn an_unconditional_burn_in_flag_is_stripped_whatever_its_casing() {
|
fn an_unconditional_burn_in_flag_is_stripped_whatever_its_casing() {
|
||||||
let url = without_server_chosen_subtitle(
|
let url = without_server_chosen_subtitle(
|
||||||
"/videos/abc/master.m3u8?api_key=k&alwaysBurnInSubtitleWhenTranscoding=true\
|
"/videos/abc/master.m3u8?ApiKey=k&alwaysBurnInSubtitleWhenTranscoding=true\
|
||||||
&subtitlestreamindex=3&SubtitleCodec=ass",
|
&subtitlestreamindex=3&SubtitleCodec=ass",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -442,7 +442,7 @@ mod tests {
|
|||||||
assert!(!url.to_lowercase().contains("subtitlecodec"), "{url}");
|
assert!(!url.to_lowercase().contains("subtitlecodec"), "{url}");
|
||||||
assert!(!url.contains("subtitlestreamindex=3"), "{url}");
|
assert!(!url.contains("subtitlestreamindex=3"), "{url}");
|
||||||
assert!(url.contains("SubtitleStreamIndex=-1"), "{url}");
|
assert!(url.contains("SubtitleStreamIndex=-1"), "{url}");
|
||||||
assert!(url.contains("api_key=k"), "{url}");
|
assert!(url.contains("ApiKey=k"), "{url}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A URL the server built without any subtitle in it still has to *say* so:
|
/// A URL the server built without any subtitle in it still has to *say* so:
|
||||||
@@ -451,10 +451,10 @@ mod tests {
|
|||||||
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
|
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
|
||||||
#[test]
|
#[test]
|
||||||
fn a_url_with_no_subtitle_params_is_still_made_to_ask_for_none() {
|
fn a_url_with_no_subtitle_params_is_still_made_to_ask_for_none() {
|
||||||
let url = without_server_chosen_subtitle("/videos/abc/master.m3u8?api_key=k");
|
let url = without_server_chosen_subtitle("/videos/abc/master.m3u8?ApiKey=k");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
url,
|
url,
|
||||||
"/videos/abc/master.m3u8?api_key=k&SubtitleStreamIndex=-1"
|
"/videos/abc/master.m3u8?ApiKey=k&SubtitleStreamIndex=-1"
|
||||||
);
|
);
|
||||||
|
|
||||||
// A bare URL is rare but must not come out malformed.
|
// A bare URL is rare but must not come out malformed.
|
||||||
|
|||||||
@@ -0,0 +1,858 @@
|
|||||||
|
//! Every Jellyfin route the online repository speaks, in one place.
|
||||||
|
//!
|
||||||
|
//! Before this module the endpoints were 57 inline `format!` literals scattered
|
||||||
|
//! through `online.rs`, query strings baked in at the point of use. That is
|
||||||
|
//! workable against exactly one server, and hostile to anything else: a second
|
||||||
|
//! route shape means a conditional at every one of them.
|
||||||
|
//!
|
||||||
|
//! Each function here takes `&ServerCapabilities` and returns a **path**
|
||||||
|
//! (`/Users/…`), except the handful documented as returning an absolute URL
|
||||||
|
//! because they are handed to a media player rather than to the JSON helpers.
|
||||||
|
//!
|
||||||
|
//! # Percent-encoding
|
||||||
|
//!
|
||||||
|
//! Values are encoded, syntax is not. A genre named `Drama & Romance` or a
|
||||||
|
//! search for `a?b` must not split into another parameter. [`Endpoint::param`]
|
||||||
|
//! encodes; [`Endpoint::raw_param`] does not and is for values this module
|
||||||
|
//! itself composed (numbers, and lists whose separator is meaningful to
|
||||||
|
//! Jellyfin — `IncludeItemTypes` splits on `,`, `Genres` on `|`, so the
|
||||||
|
//! separator survives while each element is encoded).
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-085 | DR-279
|
||||||
|
|
||||||
|
use super::capabilities::ServerCapabilities;
|
||||||
|
use super::types::{GetItemsOptions, SearchScope};
|
||||||
|
|
||||||
|
/// A path plus query string, which knows whether it needs `?` or `&` next.
|
||||||
|
///
|
||||||
|
/// The manual separator juggling this replaces produced the double-ampersand and
|
||||||
|
/// trailing-ampersand cases an earlier test file spent four assertions on.
|
||||||
|
/// Making it structural is cheaper than testing for it.
|
||||||
|
pub struct Endpoint {
|
||||||
|
buf: String,
|
||||||
|
has_query: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Endpoint {
|
||||||
|
pub fn new(path: &str) -> Self {
|
||||||
|
// A caller may hand in a path that already carries a query.
|
||||||
|
let has_query = path.contains('?');
|
||||||
|
Self {
|
||||||
|
buf: path.to_string(),
|
||||||
|
has_query,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn separator(&mut self) -> char {
|
||||||
|
if self.has_query {
|
||||||
|
'&'
|
||||||
|
} else {
|
||||||
|
self.has_query = true;
|
||||||
|
'?'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append `key=value`, percent-encoding the value.
|
||||||
|
pub fn param(mut self, key: &str, value: &str) -> Self {
|
||||||
|
let sep = self.separator();
|
||||||
|
self.buf
|
||||||
|
.push_str(&format!("{}{}={}", sep, key, urlencoding::encode(value)));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append `key=value` verbatim. Only for values this module composed.
|
||||||
|
pub fn raw_param(mut self, key: &str, value: &str) -> Self {
|
||||||
|
let sep = self.separator();
|
||||||
|
self.buf.push_str(&format!("{}{}={}", sep, key, value));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build(self) -> String {
|
||||||
|
self.buf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode each element of a list while keeping the separator Jellyfin splits on.
|
||||||
|
fn encode_list(values: impl IntoIterator<Item = impl AsRef<str>>, separator: &str) -> String {
|
||||||
|
values
|
||||||
|
.into_iter()
|
||||||
|
.map(|v| urlencoding::encode(v.as_ref()).into_owned())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(separator)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The base for a user-scoped item query.
|
||||||
|
///
|
||||||
|
/// This is the one place the two route shapes differ, and the reason the route
|
||||||
|
/// table exists at all. `user_scoped_item_routes` is `true` for every generation
|
||||||
|
/// today — see the flag's own documentation for why flipping it needs a cited
|
||||||
|
/// source rather than a guess (DR-282).
|
||||||
|
fn user_items_root(caps: &ServerCapabilities, user_id: &str) -> Endpoint {
|
||||||
|
if caps.user_scoped_item_routes {
|
||||||
|
Endpoint::new(&format!("/Users/{}/Items", user_id))
|
||||||
|
} else {
|
||||||
|
Endpoint::new("/Items").param("userId", user_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The standard field set for a list view. `People` is deliberately absent — it
|
||||||
|
/// is only wanted in the detail view, and it is not small.
|
||||||
|
const LIST_FIELDS: &str = "BackdropImageTags,ParentBackdropImageTags,UserData";
|
||||||
|
|
||||||
|
/// As [`LIST_FIELDS`], plus what the offline store needs to derive genre lists
|
||||||
|
/// and per-genre counts from cached rows.
|
||||||
|
const LIST_FIELDS_WITH_GENRES: &str =
|
||||||
|
"BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData";
|
||||||
|
|
||||||
|
// ===== Libraries and items =====
|
||||||
|
|
||||||
|
/// The user's library views.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-007, UR-085 | JA-003, DR-279
|
||||||
|
pub fn user_views(_caps: &ServerCapabilities, user_id: &str) -> String {
|
||||||
|
format!("/Users/{}/Views", user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One item, in detail. `People`, `MediaStreams` and `MediaSources` are named
|
||||||
|
/// here and nowhere else — the detail view is the only place they are wanted.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-007, UR-085 | JA-005, DR-279
|
||||||
|
pub fn item_detail(caps: &ServerCapabilities, user_id: &str, item_id: &str) -> String {
|
||||||
|
let base = if caps.user_scoped_item_routes {
|
||||||
|
Endpoint::new(&format!(
|
||||||
|
"/Users/{}/Items/{}",
|
||||||
|
user_id,
|
||||||
|
urlencoding::encode(item_id)
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Endpoint::new(&format!("/Items/{}", urlencoding::encode(item_id))).param("userId", user_id)
|
||||||
|
};
|
||||||
|
base.raw_param(
|
||||||
|
"Fields",
|
||||||
|
"BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate,UserData",
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A folder listing.
|
||||||
|
///
|
||||||
|
/// Every value is percent-encoded before it goes into the query string: these
|
||||||
|
/// are values, not URL syntax, so a space or an `&` in one must not split it
|
||||||
|
/// into another parameter.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-007, UR-067, UR-085 | DR-116, DR-212, DR-279 | UT-104, UT-206
|
||||||
|
pub fn get_items(
|
||||||
|
caps: &ServerCapabilities,
|
||||||
|
user_id: &str,
|
||||||
|
parent_id: &str,
|
||||||
|
options: Option<&GetItemsOptions>,
|
||||||
|
) -> String {
|
||||||
|
let mut ep = user_items_root(caps, user_id).param("ParentId", parent_id);
|
||||||
|
|
||||||
|
if let Some(opts) = options {
|
||||||
|
if let Some(limit) = opts.limit {
|
||||||
|
ep = ep.raw_param("Limit", &limit.to_string());
|
||||||
|
}
|
||||||
|
if let Some(start_index) = opts.start_index {
|
||||||
|
ep = ep.raw_param("StartIndex", &start_index.to_string());
|
||||||
|
}
|
||||||
|
if let Some(types) = &opts.include_item_types {
|
||||||
|
// The comma is the list separator Jellyfin splits on, so encode
|
||||||
|
// each type rather than the joined string.
|
||||||
|
ep = ep.raw_param("IncludeItemTypes", &encode_list(types, ","));
|
||||||
|
}
|
||||||
|
|
||||||
|
// An explicit sort always wins; the container's default only fills the
|
||||||
|
// gap when the caller named none. A caller that names neither gets no
|
||||||
|
// SortBy at all, leaving the server's own order intact.
|
||||||
|
//
|
||||||
|
// TRACES: UR-007 | DR-257 | UT-229
|
||||||
|
let default_sort = super::types::default_listing_sort(opts.parent_kind);
|
||||||
|
let sort_by = opts
|
||||||
|
.sort_by
|
||||||
|
.as_deref()
|
||||||
|
.or(default_sort.map(|(field, _)| field));
|
||||||
|
let sort_order = opts
|
||||||
|
.sort_order
|
||||||
|
.as_deref()
|
||||||
|
.or(default_sort.map(|(_, order)| order));
|
||||||
|
|
||||||
|
if let Some(sort_by) = sort_by {
|
||||||
|
// SortBy is likewise comma-delimited ("ParentIndexNumber,IndexNumber,
|
||||||
|
// SortName"), so encode per field.
|
||||||
|
ep = ep.raw_param("SortBy", &encode_list(sort_by.split(','), ","));
|
||||||
|
}
|
||||||
|
if let Some(sort_order) = sort_order {
|
||||||
|
ep = ep.param("SortOrder", sort_order);
|
||||||
|
}
|
||||||
|
// Jellyfin 12.0 defaults `recursive` to true when the parent is a
|
||||||
|
// library folder and `IncludeItemTypes` is set, where 10.11 listed only
|
||||||
|
// immediate children — the same request, a different result set. State
|
||||||
|
// it explicitly whenever a type filter is present so both generations
|
||||||
|
// agree, and state the behaviour that shipped rather than adopting the
|
||||||
|
// new server-side default silently.
|
||||||
|
//
|
||||||
|
// TRACES: UR-085 | DR-288
|
||||||
|
let type_filtered = opts
|
||||||
|
.include_item_types
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|types| !types.is_empty());
|
||||||
|
match (opts.recursive, type_filtered) {
|
||||||
|
(Some(recursive), _) => ep = ep.raw_param("Recursive", &recursive.to_string()),
|
||||||
|
(None, true) => ep = ep.raw_param("Recursive", "false"),
|
||||||
|
(None, false) => {}
|
||||||
|
}
|
||||||
|
if let Some(genres) = &opts.genres {
|
||||||
|
if !genres.is_empty() {
|
||||||
|
// Genre names may contain spaces or ampersands; `|` is the
|
||||||
|
// separator Jellyfin splits this one on.
|
||||||
|
ep = ep.raw_param("Genres", &encode_list(genres, "|"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TRACES: UR-067 | DR-116 | UT-104
|
||||||
|
if opts.favorites_only == Some(true) {
|
||||||
|
ep = ep.raw_param("Filters", "IsFavorite");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ep.raw_param("Fields", LIST_FIELDS_WITH_GENRES).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A "recently added" listing.
|
||||||
|
///
|
||||||
|
/// `GroupItems=true` is the load-bearing parameter: Jellyfin defaults it to
|
||||||
|
/// `false`, which returns each newly-added *leaf* separately, so importing one
|
||||||
|
/// 14-track album pushed 14 rows into "recently added" and buried everything
|
||||||
|
/// else. With grouping on, the server collapses children into the container
|
||||||
|
/// that was added — an album appears once, while movies (which have no such
|
||||||
|
/// container) are unaffected.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-024, UR-034, UR-085 | IR-024, JA-016, DR-279
|
||||||
|
pub fn latest_items(
|
||||||
|
caps: &ServerCapabilities,
|
||||||
|
user_id: &str,
|
||||||
|
parent_id: &str,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> String {
|
||||||
|
let base = if caps.user_scoped_item_routes {
|
||||||
|
Endpoint::new(&format!("/Users/{}/Items/Latest", user_id))
|
||||||
|
} else {
|
||||||
|
Endpoint::new("/Items/Latest").param("userId", user_id)
|
||||||
|
};
|
||||||
|
base.param("ParentId", parent_id)
|
||||||
|
.raw_param("Limit", &limit.unwrap_or(16).to_string())
|
||||||
|
.raw_param("GroupItems", "true")
|
||||||
|
.raw_param("Fields", LIST_FIELDS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The resume ("Continue Watching") listing.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-019, UR-085 | JA-013, DR-279
|
||||||
|
pub fn resume_items(
|
||||||
|
caps: &ServerCapabilities,
|
||||||
|
user_id: &str,
|
||||||
|
limit: usize,
|
||||||
|
include_item_types: Option<&str>,
|
||||||
|
parent_id: Option<&str>,
|
||||||
|
) -> String {
|
||||||
|
let base = if caps.user_scoped_item_routes {
|
||||||
|
Endpoint::new(&format!("/Users/{}/Items/Resume", user_id))
|
||||||
|
} else {
|
||||||
|
Endpoint::new("/Items/Resume").param("userId", user_id)
|
||||||
|
};
|
||||||
|
let ep = base
|
||||||
|
.raw_param("Limit", &limit.to_string())
|
||||||
|
.raw_param("MediaTypes", "Video");
|
||||||
|
let ep = match include_item_types {
|
||||||
|
Some(types) => ep.raw_param("IncludeItemTypes", types),
|
||||||
|
None => ep,
|
||||||
|
};
|
||||||
|
let ep = ep.raw_param("Fields", LIST_FIELDS);
|
||||||
|
match parent_id {
|
||||||
|
Some(pid) => ep.param("ParentId", pid).build(),
|
||||||
|
None => ep.build(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Next Up listing.
|
||||||
|
///
|
||||||
|
/// `EnableResumable=false` is the point of this query: the server default is
|
||||||
|
/// `true`, which makes a partially-watched episode its own series' "next up" —
|
||||||
|
/// the very episode `/Items/Resume` returns — so Continue Watching and Next Up
|
||||||
|
/// end up showing the same cards. Servers predating the parameter ignore it,
|
||||||
|
/// which is why the frontend also drops in-progress entries (DR-197).
|
||||||
|
///
|
||||||
|
/// TRACES: UR-023, UR-059, UR-085 | DR-197, DR-279, JA-014, JA-036 | UT-190, UT-191
|
||||||
|
pub fn next_up(
|
||||||
|
_caps: &ServerCapabilities,
|
||||||
|
user_id: &str,
|
||||||
|
series_id: Option<&str>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> String {
|
||||||
|
let ep = Endpoint::new("/Shows/NextUp")
|
||||||
|
.param("UserId", user_id)
|
||||||
|
.raw_param("Limit", &limit.unwrap_or(16).to_string())
|
||||||
|
.raw_param("EnableResumable", "false")
|
||||||
|
.raw_param("Fields", LIST_FIELDS);
|
||||||
|
|
||||||
|
match series_id {
|
||||||
|
Some(sid) => ep.param("SeriesId", sid).build(),
|
||||||
|
None => ep.build(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A favourites listing.
|
||||||
|
///
|
||||||
|
/// `scope` is expanded here — `SearchScope::All` yields `None`, and the
|
||||||
|
/// `IncludeItemTypes` filter is then **omitted entirely** rather than sent as a
|
||||||
|
/// union, which would silently drop every type nobody enumerated (see
|
||||||
|
/// `SearchScope::item_types`).
|
||||||
|
///
|
||||||
|
/// TRACES: UR-067, UR-085 | DR-115, DR-279, JA-033 | UT-100
|
||||||
|
pub fn favorites(
|
||||||
|
caps: &ServerCapabilities,
|
||||||
|
user_id: &str,
|
||||||
|
scope: SearchScope,
|
||||||
|
options: Option<&GetItemsOptions>,
|
||||||
|
) -> String {
|
||||||
|
let mut ep = user_items_root(caps, user_id)
|
||||||
|
.raw_param("Filters", "IsFavorite")
|
||||||
|
.raw_param("Recursive", "true");
|
||||||
|
|
||||||
|
if let Some(types) = scope.item_types() {
|
||||||
|
ep = ep.raw_param("IncludeItemTypes", &types.join(","));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jellyfin has no "date favourited", so name order is the only stable sort
|
||||||
|
// available; callers may still override it.
|
||||||
|
let sort_by = options
|
||||||
|
.and_then(|o| o.sort_by.as_deref())
|
||||||
|
.unwrap_or("SortName");
|
||||||
|
let sort_order = options
|
||||||
|
.and_then(|o| o.sort_order.as_deref())
|
||||||
|
.unwrap_or("Ascending");
|
||||||
|
ep = ep
|
||||||
|
.raw_param("SortBy", sort_by)
|
||||||
|
.raw_param("SortOrder", sort_order);
|
||||||
|
|
||||||
|
if let Some(limit) = options.and_then(|o| o.limit) {
|
||||||
|
ep = ep.raw_param("Limit", &limit.to_string());
|
||||||
|
}
|
||||||
|
if let Some(start_index) = options.and_then(|o| o.start_index) {
|
||||||
|
ep = ep.raw_param("StartIndex", &start_index.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
ep.raw_param("Fields", LIST_FIELDS_WITH_GENRES).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Items sorted by when they were last played, filtered to played ones.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-034, UR-085 | DR-279
|
||||||
|
pub fn played_items_by_date(
|
||||||
|
caps: &ServerCapabilities,
|
||||||
|
user_id: &str,
|
||||||
|
include_item_types: &str,
|
||||||
|
limit: usize,
|
||||||
|
sort_order: &str,
|
||||||
|
parent_id: Option<&str>,
|
||||||
|
) -> String {
|
||||||
|
let ep = user_items_root(caps, user_id)
|
||||||
|
.raw_param("SortBy", "DatePlayed")
|
||||||
|
.raw_param("SortOrder", sort_order)
|
||||||
|
.raw_param("IncludeItemTypes", include_item_types)
|
||||||
|
.raw_param("Limit", &limit.to_string())
|
||||||
|
.raw_param("Recursive", "true")
|
||||||
|
.raw_param("Filters", "IsPlayed")
|
||||||
|
.raw_param("Fields", LIST_FIELDS);
|
||||||
|
match parent_id {
|
||||||
|
Some(pid) => ep.param("ParentId", pid).build(),
|
||||||
|
None => ep.build(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Genres, with the item counts the frontend uses to pick a diverse subset.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-279
|
||||||
|
pub fn genres(
|
||||||
|
_caps: &ServerCapabilities,
|
||||||
|
user_id: &str,
|
||||||
|
include_item_types: &str,
|
||||||
|
parent_id: Option<&str>,
|
||||||
|
) -> String {
|
||||||
|
let ep = Endpoint::new("/Genres")
|
||||||
|
.param("UserId", user_id)
|
||||||
|
.raw_param("IncludeItemTypes", include_item_types)
|
||||||
|
.raw_param("Recursive", "true")
|
||||||
|
.raw_param("Fields", "ItemCounts");
|
||||||
|
match parent_id {
|
||||||
|
Some(pid) => ep.param("ParentId", pid).build(),
|
||||||
|
None => ep.build(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A search.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-279
|
||||||
|
pub fn search(
|
||||||
|
caps: &ServerCapabilities,
|
||||||
|
user_id: &str,
|
||||||
|
term: &str,
|
||||||
|
limit: usize,
|
||||||
|
include_item_types: Option<&[String]>,
|
||||||
|
) -> String {
|
||||||
|
let ep = user_items_root(caps, user_id)
|
||||||
|
.param("SearchTerm", term)
|
||||||
|
.raw_param("Limit", &limit.to_string())
|
||||||
|
.raw_param("Recursive", "true");
|
||||||
|
match include_item_types {
|
||||||
|
Some(types) if !types.is_empty() => ep
|
||||||
|
.raw_param("IncludeItemTypes", &encode_list(types, ","))
|
||||||
|
.build(),
|
||||||
|
_ => ep.build(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A person's filmography.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-036, UR-085 | JA-031, DR-279
|
||||||
|
pub fn items_by_person(
|
||||||
|
caps: &ServerCapabilities,
|
||||||
|
user_id: &str,
|
||||||
|
person_id: &str,
|
||||||
|
limit: usize,
|
||||||
|
include_item_types: Option<&[String]>,
|
||||||
|
) -> String {
|
||||||
|
let ep = user_items_root(caps, user_id)
|
||||||
|
.param("PersonIds", person_id)
|
||||||
|
.raw_param("Limit", &limit.to_string())
|
||||||
|
.raw_param("Recursive", "true")
|
||||||
|
.raw_param("Fields", LIST_FIELDS);
|
||||||
|
match include_item_types {
|
||||||
|
Some(types) if !types.is_empty() => ep
|
||||||
|
.raw_param("IncludeItemTypes", &encode_list(types, ","))
|
||||||
|
.build(),
|
||||||
|
_ => ep.build(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A person as an item.
|
||||||
|
///
|
||||||
|
/// Jellyfin serves people through the ordinary user-item endpoint rather than
|
||||||
|
/// anything under `/Persons`; the cast entries on an item's `People` field carry
|
||||||
|
/// the ids this is called with.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-035, UR-036, UR-085 | IR-022, JA-030, DR-279
|
||||||
|
pub fn person(caps: &ServerCapabilities, user_id: &str, person_id: &str) -> String {
|
||||||
|
if caps.user_scoped_item_routes {
|
||||||
|
format!(
|
||||||
|
"/Users/{}/Items/{}",
|
||||||
|
user_id,
|
||||||
|
urlencoding::encode(person_id)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Endpoint::new(&format!("/Items/{}", urlencoding::encode(person_id)))
|
||||||
|
.param("userId", user_id)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Items similar to one item.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-279
|
||||||
|
pub fn similar_items(
|
||||||
|
_caps: &ServerCapabilities,
|
||||||
|
item_id: &str,
|
||||||
|
user_id: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> String {
|
||||||
|
Endpoint::new(&format!("/Items/{}/Similar", urlencoding::encode(item_id)))
|
||||||
|
.param("UserId", user_id)
|
||||||
|
.raw_param("Limit", &limit.to_string())
|
||||||
|
.raw_param("Fields", LIST_FIELDS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== User data mutations =====
|
||||||
|
|
||||||
|
/// Favourite / un-favourite an item (POST to set, DELETE to clear).
|
||||||
|
///
|
||||||
|
/// TRACES: UR-067, UR-085 | JA-033, DR-279
|
||||||
|
pub fn favorite_item(_caps: &ServerCapabilities, user_id: &str, item_id: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"/Users/{}/FavoriteItems/{}",
|
||||||
|
user_id,
|
||||||
|
urlencoding::encode(item_id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark played / clear watch history (POST to set, DELETE to clear).
|
||||||
|
///
|
||||||
|
/// TRACES: UR-025, UR-085 | JA-035, DR-279
|
||||||
|
pub fn played_item(_caps: &ServerCapabilities, user_id: &str, item_id: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"/Users/{}/PlayedItems/{}",
|
||||||
|
user_id,
|
||||||
|
urlencoding::encode(item_id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Playback =====
|
||||||
|
|
||||||
|
/// Playback negotiation for one item.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-004, UR-085 | JA-021, DR-279
|
||||||
|
pub fn playback_info(_caps: &ServerCapabilities, item_id: &str) -> String {
|
||||||
|
format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Playback reporting.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-020, UR-085 | JA-010, JA-011, JA-012, DR-279
|
||||||
|
pub fn sessions_playing(_caps: &ServerCapabilities) -> &'static str {
|
||||||
|
"/Sessions/Playing"
|
||||||
|
}
|
||||||
|
pub fn sessions_playing_progress(_caps: &ServerCapabilities) -> &'static str {
|
||||||
|
"/Sessions/Playing/Progress"
|
||||||
|
}
|
||||||
|
pub fn sessions_playing_stopped(_caps: &ServerCapabilities) -> &'static str {
|
||||||
|
"/Sessions/Playing/Stopped"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live TV channels.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-279
|
||||||
|
pub fn live_tv_channels(_caps: &ServerCapabilities, user_id: &str) -> String {
|
||||||
|
Endpoint::new("/LiveTv/Channels")
|
||||||
|
.param("UserId", user_id)
|
||||||
|
.raw_param("Fields", "PrimaryImageAspectRatio,Overview")
|
||||||
|
.raw_param("EnableImageTypes", "Primary")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic channels.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-279
|
||||||
|
pub fn channels(_caps: &ServerCapabilities, user_id: &str) -> String {
|
||||||
|
Endpoint::new("/Channels").param("UserId", user_id).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Playlists =====
|
||||||
|
|
||||||
|
/// TRACES: UR-062, UR-085 | DR-279
|
||||||
|
pub fn playlists(_caps: &ServerCapabilities) -> &'static str {
|
||||||
|
"/Playlists"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A playlist as an item — used for rename and delete, which are `/Items`
|
||||||
|
/// operations rather than `/Playlists` ones.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-062, UR-085 | DR-279
|
||||||
|
pub fn playlist_as_item(_caps: &ServerCapabilities, playlist_id: &str) -> String {
|
||||||
|
format!("/Items/{}", urlencoding::encode(playlist_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: UR-062, UR-085 | DR-279
|
||||||
|
pub fn playlist_items(_caps: &ServerCapabilities, playlist_id: &str, user_id: &str) -> String {
|
||||||
|
Endpoint::new(&format!(
|
||||||
|
"/Playlists/{}/Items",
|
||||||
|
urlencoding::encode(playlist_id)
|
||||||
|
))
|
||||||
|
.param("UserId", user_id)
|
||||||
|
.raw_param(
|
||||||
|
"Fields",
|
||||||
|
"PrimaryImageTag,Artists,AlbumId,Album,AlbumArtist,RunTimeTicks,ArtistItems",
|
||||||
|
)
|
||||||
|
.raw_param("StartIndex", "0")
|
||||||
|
.raw_param("Limit", "10000")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: UR-062, UR-085 | DR-279
|
||||||
|
pub fn playlist_items_add(_caps: &ServerCapabilities, playlist_id: &str, ids: &str) -> String {
|
||||||
|
Endpoint::new(&format!(
|
||||||
|
"/Playlists/{}/Items",
|
||||||
|
urlencoding::encode(playlist_id)
|
||||||
|
))
|
||||||
|
.param("Ids", ids)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: UR-062, UR-085 | DR-279
|
||||||
|
pub fn playlist_items_remove(
|
||||||
|
_caps: &ServerCapabilities,
|
||||||
|
playlist_id: &str,
|
||||||
|
entry_ids: &str,
|
||||||
|
) -> String {
|
||||||
|
Endpoint::new(&format!(
|
||||||
|
"/Playlists/{}/Items",
|
||||||
|
urlencoding::encode(playlist_id)
|
||||||
|
))
|
||||||
|
.param("EntryIds", entry_ids)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: UR-062, UR-085 | DR-279
|
||||||
|
pub fn playlist_item_move(
|
||||||
|
_caps: &ServerCapabilities,
|
||||||
|
playlist_id: &str,
|
||||||
|
item_id: &str,
|
||||||
|
new_index: u32,
|
||||||
|
) -> String {
|
||||||
|
format!(
|
||||||
|
"/Playlists/{}/Items/{}/Move/{}",
|
||||||
|
urlencoding::encode(playlist_id),
|
||||||
|
urlencoding::encode(item_id),
|
||||||
|
new_index
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Plugin =====
|
||||||
|
|
||||||
|
/// The JRay plugin's per-item context. Not core Jellyfin; absent servers 404 and
|
||||||
|
/// the caller treats that as "no context", so it needs no capability flag.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-279
|
||||||
|
pub fn jray_context(_caps: &ServerCapabilities, item_id: &str, position_seconds: f64) -> String {
|
||||||
|
format!(
|
||||||
|
"/Plugins/JRay/Items/{}/jray?t={}",
|
||||||
|
urlencoding::encode(item_id),
|
||||||
|
position_seconds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn caps() -> ServerCapabilities {
|
||||||
|
ServerCapabilities::assumed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The builder must never emit a double or trailing separator, and must use
|
||||||
|
/// `?` exactly once. This is structural now rather than asserted at every
|
||||||
|
/// call site.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-279
|
||||||
|
#[test]
|
||||||
|
fn query_separators_are_structural() {
|
||||||
|
let url = Endpoint::new("/Items")
|
||||||
|
.param("a", "1")
|
||||||
|
.param("b", "2")
|
||||||
|
.raw_param("c", "3")
|
||||||
|
.build();
|
||||||
|
assert_eq!(url, "/Items?a=1&b=2&c=3");
|
||||||
|
assert_eq!(url.matches('?').count(), 1);
|
||||||
|
assert!(!url.contains("&&"));
|
||||||
|
assert!(!url.ends_with('&'));
|
||||||
|
|
||||||
|
// A path that already carries a query continues it rather than
|
||||||
|
// starting a second one.
|
||||||
|
let continued = Endpoint::new("/Items?x=0").param("y", "1").build();
|
||||||
|
assert_eq!(continued, "/Items?x=0&y=1");
|
||||||
|
assert_eq!(continued.matches('?').count(), 1);
|
||||||
|
|
||||||
|
// No parameters at all means no `?`.
|
||||||
|
assert_eq!(Endpoint::new("/Items").build(), "/Items");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Values are encoded, list separators are not.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-007, UR-085 | DR-212, DR-279 | UT-206
|
||||||
|
#[test]
|
||||||
|
fn values_are_encoded_but_list_separators_survive() {
|
||||||
|
let url = Endpoint::new("/x").param("SearchTerm", "a?b&c d").build();
|
||||||
|
assert!(url.contains("SearchTerm=a%3Fb%26c%20d"), "{url}");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
encode_list(["Drama & Romance", "Sci-Fi"], "|"),
|
||||||
|
"Drama%20%26%20Romance|Sci-Fi"
|
||||||
|
);
|
||||||
|
assert_eq!(encode_list(["Movie", "Series"], ","), "Movie,Series");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user-scoped split is the reason this module exists. Both shapes must
|
||||||
|
/// be well-formed, and the default must be byte-identical to what shipped.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-279, DR-282
|
||||||
|
#[test]
|
||||||
|
fn both_user_scoped_route_shapes_are_well_formed() {
|
||||||
|
let legacy = caps();
|
||||||
|
assert!(legacy.user_scoped_item_routes, "the shipped default");
|
||||||
|
let url = get_items(&legacy, "u1", "lib-1", None);
|
||||||
|
assert!(url.starts_with("/Users/u1/Items?ParentId=lib-1"), "{url}");
|
||||||
|
|
||||||
|
let mut modern = caps();
|
||||||
|
modern.user_scoped_item_routes = false;
|
||||||
|
let url = get_items(&modern, "u1", "lib-1", None);
|
||||||
|
assert!(url.starts_with("/Items?userId=u1&ParentId=lib-1"), "{url}");
|
||||||
|
assert_eq!(url.matches('?').count(), 1, "{url}");
|
||||||
|
assert!(!url.contains("/Users/"), "{url}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every route must be well-formed under *both* shapes — a flipped flag
|
||||||
|
/// must not produce a malformed URL anywhere.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-279, DR-282
|
||||||
|
#[test]
|
||||||
|
fn no_route_is_malformed_under_either_shape() {
|
||||||
|
for user_scoped in [true, false] {
|
||||||
|
let mut c = caps();
|
||||||
|
c.user_scoped_item_routes = user_scoped;
|
||||||
|
|
||||||
|
let routes = vec![
|
||||||
|
user_views(&c, "u1"),
|
||||||
|
item_detail(&c, "u1", "i1"),
|
||||||
|
get_items(&c, "u1", "p1", None),
|
||||||
|
latest_items(&c, "u1", "p1", Some(8)),
|
||||||
|
resume_items(&c, "u1", 10, None, None),
|
||||||
|
resume_items(&c, "u1", 10, Some("Movie"), Some("lib-9")),
|
||||||
|
next_up(&c, "u1", Some("s1"), Some(5)),
|
||||||
|
favorites(&c, "u1", SearchScope::All, None),
|
||||||
|
played_items_by_date(&c, "u1", "Audio", 20, "Descending", None),
|
||||||
|
genres(&c, "u1", "MusicAlbum", Some("lib-1")),
|
||||||
|
search(&c, "u1", "query", 25, Some(&["Movie".to_string()])),
|
||||||
|
items_by_person(&c, "u1", "p9", 50, None),
|
||||||
|
person(&c, "u1", "p9"),
|
||||||
|
similar_items(&c, "i1", "u1", 12),
|
||||||
|
favorite_item(&c, "u1", "i1"),
|
||||||
|
played_item(&c, "u1", "i1"),
|
||||||
|
playback_info(&c, "i1"),
|
||||||
|
live_tv_channels(&c, "u1"),
|
||||||
|
channels(&c, "u1"),
|
||||||
|
playlist_as_item(&c, "pl1"),
|
||||||
|
playlist_items(&c, "pl1", "u1"),
|
||||||
|
playlist_items_add(&c, "pl1", "a,b"),
|
||||||
|
playlist_items_remove(&c, "pl1", "e1"),
|
||||||
|
playlist_item_move(&c, "pl1", "i1", 3u32),
|
||||||
|
jray_context(&c, "i1", 42.5),
|
||||||
|
];
|
||||||
|
|
||||||
|
for route in routes {
|
||||||
|
assert!(route.starts_with('/'), "{route}");
|
||||||
|
assert!(!route.contains("&&"), "{route}");
|
||||||
|
assert!(!route.contains("?&"), "{route}");
|
||||||
|
assert!(!route.ends_with('&'), "{route}");
|
||||||
|
assert!(!route.ends_with('?'), "{route}");
|
||||||
|
assert!(
|
||||||
|
route.matches('?').count() <= 1,
|
||||||
|
"more than one query separator: {route}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: UR-024, UR-034 | IR-024, JA-016
|
||||||
|
#[test]
|
||||||
|
fn latest_items_groups_children_into_containers() {
|
||||||
|
let url = latest_items(&caps(), "u1", "lib-1", Some(16));
|
||||||
|
assert!(url.contains("GroupItems=true"), "{url}");
|
||||||
|
assert!(url.contains("ParentId=lib-1"), "{url}");
|
||||||
|
assert!(url.contains("Limit=16"), "{url}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: UR-059 | DR-197, JA-036 | UT-190, UT-191
|
||||||
|
#[test]
|
||||||
|
fn next_up_excludes_resumable_and_scopes_to_series() {
|
||||||
|
let url = next_up(&caps(), "u1", None, Some(12));
|
||||||
|
assert!(url.contains("EnableResumable=false"), "{url}");
|
||||||
|
assert!(url.contains("UserId=u1"), "{url}");
|
||||||
|
assert!(url.contains("Limit=12"), "{url}");
|
||||||
|
assert!(!url.contains("SeriesId"), "{url}");
|
||||||
|
|
||||||
|
let scoped = next_up(&caps(), "u1", Some("series-a"), None);
|
||||||
|
assert!(scoped.contains("SeriesId=series-a"), "{scoped}");
|
||||||
|
assert!(scoped.contains("Limit=16"), "default limit: {scoped}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `All` must omit the type filter entirely rather than send a union, which
|
||||||
|
/// would silently drop every type nobody enumerated.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-067 | DR-115 | UT-100
|
||||||
|
#[test]
|
||||||
|
fn favorites_all_scope_omits_the_type_filter() {
|
||||||
|
let url = favorites(&caps(), "u1", SearchScope::All, None);
|
||||||
|
assert!(!url.contains("IncludeItemTypes"), "{url}");
|
||||||
|
assert!(url.contains("Filters=IsFavorite"), "{url}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: UR-067 | DR-115 | UT-100
|
||||||
|
#[test]
|
||||||
|
fn favorites_honours_paging_and_sort() {
|
||||||
|
let url = favorites(
|
||||||
|
&caps(),
|
||||||
|
"u1",
|
||||||
|
SearchScope::All,
|
||||||
|
Some(&GetItemsOptions {
|
||||||
|
limit: Some(20),
|
||||||
|
start_index: Some(40),
|
||||||
|
sort_by: Some("Random".to_string()),
|
||||||
|
sort_order: Some("Descending".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert!(url.contains("&Limit=20"), "{url}");
|
||||||
|
assert!(url.contains("&StartIndex=40"), "{url}");
|
||||||
|
assert!(url.contains("&SortBy=Random&SortOrder=Descending"), "{url}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The detail view is the only caller that wants People/MediaStreams; a list
|
||||||
|
/// query must not drag them along.
|
||||||
|
///
|
||||||
|
/// Jellyfin 12.0 changed `GetItems` to default `recursive` to **true** when
|
||||||
|
/// the parent is a library folder and `IncludeItemTypes` is set — so the
|
||||||
|
/// identical request returns a different result set on the two generations.
|
||||||
|
/// Sending an explicit value makes them agree, and `false` is what shipped.
|
||||||
|
///
|
||||||
|
/// Source: `ItemsController.cs` in v12.0 — `if (folder is ICollectionFolder
|
||||||
|
/// && includeItemTypes.Length > 0) { recursive ??= true; }`
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-288
|
||||||
|
#[test]
|
||||||
|
fn a_type_filtered_listing_always_states_recursive() {
|
||||||
|
let filtered = get_items(
|
||||||
|
&caps(),
|
||||||
|
"u1",
|
||||||
|
"lib-1",
|
||||||
|
Some(&GetItemsOptions {
|
||||||
|
include_item_types: Some(vec!["Movie".to_string()]),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
filtered.contains("Recursive="),
|
||||||
|
"a type-filtered listing must state Recursive or 12.0 will infer a \
|
||||||
|
different one than 10.11: {filtered}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
filtered.contains("Recursive=false"),
|
||||||
|
"and it must state the behaviour that shipped: {filtered}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// An explicit choice by the caller still wins.
|
||||||
|
let explicit = get_items(
|
||||||
|
&caps(),
|
||||||
|
"u1",
|
||||||
|
"lib-1",
|
||||||
|
Some(&GetItemsOptions {
|
||||||
|
include_item_types: Some(vec!["Movie".to_string()]),
|
||||||
|
recursive: Some(true),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert!(explicit.contains("Recursive=true"), "{explicit}");
|
||||||
|
assert_eq!(explicit.matches("Recursive=").count(), 1, "{explicit}");
|
||||||
|
|
||||||
|
// No type filter, no inference to defend against, no parameter.
|
||||||
|
let plain = get_items(&caps(), "u1", "lib-1", None);
|
||||||
|
assert!(!plain.contains("Recursive="), "{plain}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: UR-007 | DR-279
|
||||||
|
#[test]
|
||||||
|
fn only_the_detail_route_requests_the_heavy_fields() {
|
||||||
|
assert!(item_detail(&caps(), "u1", "i1").contains("People"));
|
||||||
|
assert!(!get_items(&caps(), "u1", "p1", None).contains("People"));
|
||||||
|
assert!(!latest_items(&caps(), "u1", "p1", None).contains("MediaStreams"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
//! The online repository, exercised against a real HTTP server on both Jellyfin
|
||||||
|
//! generations.
|
||||||
|
//!
|
||||||
|
//! These are the tests DR-281 exists for: every assertion here is about what the
|
||||||
|
//! client actually put on the wire, or about what it did with a response it
|
||||||
|
//! actually received. Nothing here reimplements a URL builder.
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-085 | DR-281
|
||||||
|
|
||||||
|
use super::server_fixture::{target, FakeJellyfin, BOTH_GENERATIONS, V10_11, V12};
|
||||||
|
use super::types::{GetItemsOptions, SearchScope};
|
||||||
|
use super::MediaRepository;
|
||||||
|
|
||||||
|
/// Jellyfin 12.0 disables `X-Emby-Authorization` by default — including on
|
||||||
|
/// upgraded servers, via a migration that flips `EnableLegacyAuthorization` to
|
||||||
|
/// false. `Authorization` with the same `MediaBrowser` scheme is ungated on both
|
||||||
|
/// generations, so there is one correct spelling rather than a branch.
|
||||||
|
///
|
||||||
|
/// This is the assertion that would have caught the breakage: it looks at the
|
||||||
|
/// header the server received, not at a string the client built.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-287 | IT-019
|
||||||
|
#[tokio::test]
|
||||||
|
async fn every_request_authenticates_with_the_non_deprecated_header() {
|
||||||
|
for version in BOTH_GENERATIONS {
|
||||||
|
let fake = FakeJellyfin::start(version).await;
|
||||||
|
let repo = fake.repository();
|
||||||
|
|
||||||
|
repo.get_libraries().await.expect("libraries");
|
||||||
|
|
||||||
|
let request = fake.only_request().await;
|
||||||
|
|
||||||
|
let auth = request
|
||||||
|
.headers
|
||||||
|
.get("authorization")
|
||||||
|
.unwrap_or_else(|| panic!("{version}: no Authorization header was sent"))
|
||||||
|
.to_str()
|
||||||
|
.expect("header is ascii");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
auth.starts_with("MediaBrowser "),
|
||||||
|
"{version}: Authorization must use the MediaBrowser scheme, got {auth:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
auth.contains(r#"Token="token-abc""#),
|
||||||
|
"{version}: the token must reach the server, got {auth:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
request.headers.get("x-emby-authorization").is_none(),
|
||||||
|
"{version}: X-Emby-Authorization is disabled by default on 12.0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A listing must parse into domain items on both generations. `BaseItemDto` was
|
||||||
|
/// verified to be purely additive between 10.11.5 and 12.0, so one parse path is
|
||||||
|
/// correct for both — this is the test that would notice if that stopped holding.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-007, UR-085 | DR-281 | IT-020
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_listing_parses_on_both_generations() {
|
||||||
|
for version in BOTH_GENERATIONS {
|
||||||
|
let fake = FakeJellyfin::start(version).await;
|
||||||
|
let result = fake
|
||||||
|
.repository()
|
||||||
|
.get_items("lib-1", None)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| panic!("{version}: listing failed: {e:?}"));
|
||||||
|
|
||||||
|
assert_eq!(result.items.len(), 1, "{version}");
|
||||||
|
assert_eq!(result.items[0].id, "item-1", "{version}");
|
||||||
|
assert_eq!(result.items[0].name, "A Film", "{version}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jellyfin 12.0 defaults `recursive` to true when the parent is a library
|
||||||
|
/// folder and `IncludeItemTypes` is set, where 10.11 listed immediate children —
|
||||||
|
/// the identical request, a different result set. The client must state it, so
|
||||||
|
/// that the two generations agree.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-288 | IT-021
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_type_filtered_listing_states_recursive_on_the_wire() {
|
||||||
|
for version in BOTH_GENERATIONS {
|
||||||
|
let fake = FakeJellyfin::start(version).await;
|
||||||
|
|
||||||
|
fake.repository()
|
||||||
|
.get_items(
|
||||||
|
"lib-1",
|
||||||
|
Some(GetItemsOptions {
|
||||||
|
include_item_types: Some(vec!["Movie".to_string()]),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("listing");
|
||||||
|
|
||||||
|
let sent = target(&fake.only_request().await);
|
||||||
|
assert!(
|
||||||
|
sent.contains("Recursive="),
|
||||||
|
"{version}: without an explicit Recursive the two generations disagree: {sent}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The library listing goes to the route the capabilities selected, and comes
|
||||||
|
/// back parsed. Both generations still serve the user-scoped family — only six
|
||||||
|
/// routes were removed in 12.0 and none of them are these.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-007, UR-085 | DR-282 | IT-022
|
||||||
|
#[tokio::test]
|
||||||
|
async fn libraries_resolve_on_both_generations() {
|
||||||
|
for version in BOTH_GENERATIONS {
|
||||||
|
let fake = FakeJellyfin::start(version).await;
|
||||||
|
let libraries = fake
|
||||||
|
.repository()
|
||||||
|
.get_libraries()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| panic!("{version}: {e:?}"));
|
||||||
|
|
||||||
|
assert_eq!(libraries.len(), 1, "{version}");
|
||||||
|
assert_eq!(libraries[0].id, "lib-1", "{version}");
|
||||||
|
|
||||||
|
let sent = target(&fake.only_request().await);
|
||||||
|
assert!(sent.starts_with("/Users/user-1/Views"), "{version}: {sent}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flipping the user-scoped flag must actually change the wire request, and the
|
||||||
|
/// response must still parse. Nothing selects `false` today, so without this the
|
||||||
|
/// alternative route shape would be untested code waiting to be switched on.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-282 | IT-023
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_alternative_route_shape_works_end_to_end() {
|
||||||
|
let fake = FakeJellyfin::start(V12).await;
|
||||||
|
|
||||||
|
let mut capabilities = super::capabilities::ServerCapabilities::from_reported(V12);
|
||||||
|
capabilities.user_scoped_item_routes = false;
|
||||||
|
let repo = fake.repository().with_capabilities(capabilities);
|
||||||
|
|
||||||
|
let result = repo.get_items("lib-1", None).await.expect("listing");
|
||||||
|
assert_eq!(result.items.len(), 1);
|
||||||
|
|
||||||
|
let sent = target(&fake.only_request().await);
|
||||||
|
assert!(sent.starts_with("/Items?"), "{sent}");
|
||||||
|
assert!(sent.contains("userId=user-1"), "{sent}");
|
||||||
|
assert!(!sent.contains("/Users/"), "{sent}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Favourites carry the filter that makes them favourites, on both generations.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-067, UR-085 | DR-281 | IT-024
|
||||||
|
#[tokio::test]
|
||||||
|
async fn favourites_filter_reaches_the_server() {
|
||||||
|
for version in BOTH_GENERATIONS {
|
||||||
|
let fake = FakeJellyfin::start(version).await;
|
||||||
|
|
||||||
|
fake.repository()
|
||||||
|
.get_favorites(SearchScope::All, None)
|
||||||
|
.await
|
||||||
|
.expect("favourites");
|
||||||
|
|
||||||
|
let sent = target(&fake.only_request().await);
|
||||||
|
assert!(sent.contains("Filters=IsFavorite"), "{version}: {sent}");
|
||||||
|
assert!(
|
||||||
|
!sent.contains("IncludeItemTypes"),
|
||||||
|
"{version}: All scope must omit the type filter rather than send a \
|
||||||
|
union, which would drop every type nobody enumerated: {sent}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stream URL is handed to mpv / ExoPlayer / an HTML5 `<video>`, none of which
|
||||||
|
/// can set a header — so its token must ride in the query string. `ApiKey` is
|
||||||
|
/// ungated on both generations and is what the server itself emits; `api_key` is
|
||||||
|
/// gated off by default on 12.0.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-004, UR-085 | DR-287 | IT-025
|
||||||
|
#[tokio::test]
|
||||||
|
async fn player_facing_urls_carry_the_ungated_query_token() {
|
||||||
|
for version in BOTH_GENERATIONS {
|
||||||
|
let fake = FakeJellyfin::start(version).await;
|
||||||
|
let url = fake
|
||||||
|
.repository()
|
||||||
|
.get_audio_stream_url("track-1")
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| panic!("{version}: {e:?}"));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
url.contains("ApiKey=token-abc"),
|
||||||
|
"{version}: a player cannot send a header, so the token must be in \
|
||||||
|
the query — and spelled ApiKey: {url}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!url.contains("api_key="),
|
||||||
|
"{version}: api_key is disabled by default on 12.0: {url}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The capability resolution is driven by what the server reported, not by a
|
||||||
|
/// value a test poked in — this is what makes the other tests here meaningful.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-280 | IT-026
|
||||||
|
#[tokio::test]
|
||||||
|
async fn capabilities_come_from_the_version_the_server_reported() {
|
||||||
|
use super::capabilities::ServerGeneration;
|
||||||
|
|
||||||
|
let old = FakeJellyfin::start(V10_11).await;
|
||||||
|
assert_eq!(
|
||||||
|
old.repository().capabilities().generation,
|
||||||
|
ServerGeneration::V10_11
|
||||||
|
);
|
||||||
|
|
||||||
|
let new = FakeJellyfin::start(V12).await;
|
||||||
|
assert_eq!(
|
||||||
|
new.repository().capabilities().generation,
|
||||||
|
ServerGeneration::V12Plus
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!new.repository()
|
||||||
|
.capabilities()
|
||||||
|
.supports_manifest_container_direct_play,
|
||||||
|
"12.0 makes manifest-container sources ineligible for direct play"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,38 @@ use tokio::time::{timeout, Duration};
|
|||||||
use super::exclusions::ExcludeHidden;
|
use super::exclusions::ExcludeHidden;
|
||||||
use super::{types::*, MediaRepository, OfflineRepository, OnlineRepository};
|
use super::{types::*, MediaRepository, OfflineRepository, OnlineRepository};
|
||||||
|
|
||||||
|
/// The cache side of a cache-first query.
|
||||||
|
///
|
||||||
|
/// Either the cache answered inside the fast path, or it is still working and
|
||||||
|
/// the query can be collected later. Keeping the slow case *addressable* rather
|
||||||
|
/// than discarding it is what lets an offline query fall back to cached content
|
||||||
|
/// after the server leg fails.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-002 | DR-013
|
||||||
|
enum CacheLeg<T> {
|
||||||
|
/// The cache answered within [`HybridRepository::CACHE_FAST_PATH`].
|
||||||
|
Ready(Result<T, RepoError>),
|
||||||
|
/// Still running. Awaiting the handle yields the answer eventually.
|
||||||
|
Slow(tokio::task::JoinHandle<Result<T, RepoError>>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> CacheLeg<T> {
|
||||||
|
/// Split into the fast-path answer and the still-running query. Exactly one
|
||||||
|
/// side is `Some`.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
fn split(
|
||||||
|
self,
|
||||||
|
) -> (
|
||||||
|
Option<Result<T, RepoError>>,
|
||||||
|
Option<tokio::task::JoinHandle<Result<T, RepoError>>>,
|
||||||
|
) {
|
||||||
|
match self {
|
||||||
|
CacheLeg::Ready(result) => (Some(result), None),
|
||||||
|
CacheLeg::Slow(handle) => (None, Some(handle)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Hybrid repository combining online and offline data sources
|
/// Hybrid repository combining online and offline data sources
|
||||||
///
|
///
|
||||||
/// Uses cache-first parallel racing strategy:
|
/// Uses cache-first parallel racing strategy:
|
||||||
@@ -379,35 +411,12 @@ impl HybridRepository {
|
|||||||
/// @req: DR-013 - Repository pattern for online/offline data access
|
/// @req: DR-013 - Repository pattern for online/offline data access
|
||||||
///
|
///
|
||||||
/// TRACES: UR-002, UR-076 | DR-013, DR-209
|
/// TRACES: UR-002, UR-076 | DR-013, DR-209
|
||||||
async fn parallel_race<T, F1, F2>(
|
async fn parallel_race<T, F2>(cache: CacheLeg<T>, server_future: F2) -> Result<T, RepoError>
|
||||||
&self,
|
|
||||||
cache_future: F1,
|
|
||||||
server_future: F2,
|
|
||||||
) -> Result<T, RepoError>
|
|
||||||
where
|
where
|
||||||
T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static,
|
T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static,
|
||||||
F1: std::future::Future<Output = Result<T, RepoError>> + Send,
|
|
||||||
F2: std::future::Future<Output = Result<T, RepoError>> + Send,
|
F2: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||||
{
|
{
|
||||||
// Try cache first (100ms timeout already applied by callers)
|
Self::race_with_refresh(cache, server_future, || {}).await
|
||||||
let cache_result = cache_future.await.map(ExcludeHidden::without_excluded);
|
|
||||||
|
|
||||||
if let Ok(data) = &cache_result {
|
|
||||||
if data.has_content() {
|
|
||||||
debug!("[HybridRepo] Cache hit, returning immediately");
|
|
||||||
return Ok(data.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache miss — fall back to server
|
|
||||||
debug!("[HybridRepo] Cache miss, querying server");
|
|
||||||
match server_future.await {
|
|
||||||
Ok(data) => Ok(data.without_excluded()),
|
|
||||||
Err(e) => {
|
|
||||||
// Server failed, try to return cache even if empty
|
|
||||||
cache_result.or(Err(e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`Self::parallel_race`], plus a callback fired on the fast path so the
|
/// [`Self::parallel_race`], plus a callback fired on the fast path so the
|
||||||
@@ -424,21 +433,20 @@ impl HybridRepository {
|
|||||||
/// already being fetched and cached by the normal path.
|
/// already being fetched and cached by the normal path.
|
||||||
///
|
///
|
||||||
/// TRACES: UR-002, UR-025, UR-076 | DR-155, DR-209
|
/// TRACES: UR-002, UR-025, UR-076 | DR-155, DR-209
|
||||||
async fn race_with_refresh<T, F1, F2, R>(
|
async fn race_with_refresh<T, F2, R>(
|
||||||
&self,
|
cache: CacheLeg<T>,
|
||||||
cache_future: F1,
|
|
||||||
server_future: F2,
|
server_future: F2,
|
||||||
on_cache_hit: R,
|
on_cache_hit: R,
|
||||||
) -> Result<T, RepoError>
|
) -> Result<T, RepoError>
|
||||||
where
|
where
|
||||||
T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static,
|
T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static,
|
||||||
F1: std::future::Future<Output = Result<T, RepoError>> + Send,
|
|
||||||
F2: std::future::Future<Output = Result<T, RepoError>> + Send,
|
F2: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||||
R: FnOnce(),
|
R: FnOnce(),
|
||||||
{
|
{
|
||||||
let cache_result = cache_future.await.map(ExcludeHidden::without_excluded);
|
let (fast, slow) = cache.split();
|
||||||
|
let fast = fast.map(|r| r.map(ExcludeHidden::without_excluded));
|
||||||
|
|
||||||
if let Ok(data) = &cache_result {
|
if let Some(Ok(data)) = &fast {
|
||||||
if data.has_content() {
|
if data.has_content() {
|
||||||
debug!("[HybridRepo] Cache hit, returning immediately (refreshing in background)");
|
debug!("[HybridRepo] Cache hit, returning immediately (refreshing in background)");
|
||||||
on_cache_hit();
|
on_cache_hit();
|
||||||
@@ -446,21 +454,81 @@ impl HybridRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!("[HybridRepo] Cache miss, querying server");
|
debug!("[HybridRepo] Cache miss or slow, querying server");
|
||||||
match server_future.await {
|
match server_future.await {
|
||||||
Ok(data) => Ok(data.without_excluded()),
|
Ok(data) => Ok(data.without_excluded()),
|
||||||
Err(e) => cache_result.or(Err(e)),
|
Err(e) => {
|
||||||
|
// The server cannot answer. If the cache is still working, it is
|
||||||
|
// now the only thing that can, so wait it out rather than
|
||||||
|
// reporting the server's failure over data we are about to hold.
|
||||||
|
// This is the offline path: a cache read slowed by a concurrent
|
||||||
|
// write used to surface as a network error.
|
||||||
|
if let Some(handle) = slow {
|
||||||
|
debug!("[HybridRepo] Server failed; waiting for the slow cache query");
|
||||||
|
return match handle.await {
|
||||||
|
Ok(Ok(data)) => Ok(data.without_excluded()),
|
||||||
|
Ok(Err(cache_err)) => {
|
||||||
|
debug!("[HybridRepo] Slow cache query also failed: {cache_err}");
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
Err(join) => {
|
||||||
|
debug!("[HybridRepo] Slow cache query panicked: {join}");
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Cache answered in time but had nothing: return that, so an
|
||||||
|
// empty-but-valid cached listing still beats a network error.
|
||||||
|
fast.unwrap_or(Err(e))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simple timeout wrapper for cache queries (100ms timeout)
|
/// How long the cache gets to answer before a query falls through to the
|
||||||
|
/// server. Short on purpose: this bounds how long a *cache hit* may delay
|
||||||
|
/// the UI, not how long the query is allowed to take.
|
||||||
|
const CACHE_FAST_PATH: Duration = Duration::from_millis(100);
|
||||||
|
|
||||||
|
/// Start a cache query and give it [`Self::CACHE_FAST_PATH`] to answer.
|
||||||
///
|
///
|
||||||
/// @req: DR-013 - Repository pattern (cache-first with timeout)
|
/// Missing the deadline does **not** cancel the query — it keeps running on
|
||||||
|
/// its own task and [`CacheLeg::settle`] can still collect it. That
|
||||||
|
/// distinction is the whole point. The database is one SQLite connection
|
||||||
|
/// behind one mutex, so a concurrent write (a sync drain, a bulk
|
||||||
|
/// `save_to_cache`) blocks reads for its duration and this deadline trips
|
||||||
|
/// routinely on slow storage. Treating that as "the cache is empty" while
|
||||||
|
/// throwing the answer away meant that offline — where the server leg also
|
||||||
|
/// fails — browsing surfaced a network error instead of the cached content
|
||||||
|
/// sitting right there on disk.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-002 | DR-013
|
||||||
|
async fn cache_leg<T>(
|
||||||
|
future: impl std::future::Future<Output = Result<T, RepoError>> + Send + 'static,
|
||||||
|
) -> CacheLeg<T>
|
||||||
|
where
|
||||||
|
T: Send + 'static,
|
||||||
|
{
|
||||||
|
// `&mut handle` so the timeout borrows the join handle rather than
|
||||||
|
// consuming it: on expiry the task is still ours to collect.
|
||||||
|
let mut handle = tokio::spawn(future);
|
||||||
|
match timeout(Self::CACHE_FAST_PATH, &mut handle).await {
|
||||||
|
Ok(Ok(result)) => CacheLeg::Ready(result),
|
||||||
|
Ok(Err(join)) => CacheLeg::Ready(Err(RepoError::Database {
|
||||||
|
message: format!("Cache query failed: {join}"),
|
||||||
|
})),
|
||||||
|
Err(_) => {
|
||||||
|
debug!("[HybridRepo] Cache missed the fast path; leaving it running");
|
||||||
|
CacheLeg::Slow(handle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Await a cache query that is still running, however long it takes.
|
||||||
async fn cache_with_timeout<T>(
|
async fn cache_with_timeout<T>(
|
||||||
&self,
|
&self,
|
||||||
future: impl std::future::Future<Output = Result<T, RepoError>> + Send,
|
future: impl std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||||
) -> Result<T, RepoError> {
|
) -> Result<T, RepoError> {
|
||||||
timeout(Duration::from_millis(100), future)
|
timeout(Self::CACHE_FAST_PATH, future)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
Err(RepoError::Database {
|
Err(RepoError::Database {
|
||||||
@@ -657,7 +725,7 @@ impl MediaRepository for HybridRepository {
|
|||||||
let item_id = item_id.to_string();
|
let item_id = item_id.to_string();
|
||||||
let item_id_clone = item_id.clone();
|
let item_id_clone = item_id.clone();
|
||||||
|
|
||||||
let cache_future = self.cache_with_timeout(async move { offline.get_item(&item_id).await });
|
let cache_future = Self::cache_leg(async move { offline.get_item(&item_id).await }).await;
|
||||||
|
|
||||||
let online_for_refresh = Arc::clone(&self.online);
|
let online_for_refresh = Arc::clone(&self.online);
|
||||||
let offline_for_save = Arc::clone(&self.offline);
|
let offline_for_save = Arc::clone(&self.offline);
|
||||||
@@ -683,8 +751,7 @@ impl MediaRepository for HybridRepository {
|
|||||||
|
|
||||||
let server_future = async move { online.get_item(&item_id_clone).await };
|
let server_future = async move { online.get_item(&item_id_clone).await };
|
||||||
|
|
||||||
self.race_with_refresh(cache_future, server_future, on_cache_hit)
|
Self::race_with_refresh(cache_future, server_future, on_cache_hit).await
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_latest_items(
|
async fn get_latest_items(
|
||||||
@@ -698,13 +765,13 @@ impl MediaRepository for HybridRepository {
|
|||||||
let parent_id_clone = parent_id.clone();
|
let parent_id_clone = parent_id.clone();
|
||||||
let limit_clone = limit;
|
let limit_clone = limit;
|
||||||
|
|
||||||
let cache_future = self
|
let cache_future =
|
||||||
.cache_with_timeout(async move { offline.get_latest_items(&parent_id, limit).await });
|
Self::cache_leg(async move { offline.get_latest_items(&parent_id, limit).await }).await;
|
||||||
|
|
||||||
let server_future =
|
let server_future =
|
||||||
async move { online.get_latest_items(&parent_id_clone, limit_clone).await };
|
async move { online.get_latest_items(&parent_id_clone, limit_clone).await };
|
||||||
|
|
||||||
self.parallel_race(cache_future, server_future).await
|
Self::parallel_race(cache_future, server_future).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_resume_items(
|
async fn get_resume_items(
|
||||||
@@ -718,11 +785,12 @@ impl MediaRepository for HybridRepository {
|
|||||||
let parent_id_clone = parent_id_str.clone();
|
let parent_id_clone = parent_id_str.clone();
|
||||||
let limit_clone = limit;
|
let limit_clone = limit;
|
||||||
|
|
||||||
let cache_future = self.cache_with_timeout(async move {
|
let cache_future = Self::cache_leg(async move {
|
||||||
offline
|
offline
|
||||||
.get_resume_items(parent_id_str.as_deref(), limit)
|
.get_resume_items(parent_id_str.as_deref(), limit)
|
||||||
.await
|
.await
|
||||||
});
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
let server_future = async move {
|
let server_future = async move {
|
||||||
online
|
online
|
||||||
@@ -730,7 +798,7 @@ impl MediaRepository for HybridRepository {
|
|||||||
.await
|
.await
|
||||||
};
|
};
|
||||||
|
|
||||||
self.parallel_race(cache_future, server_future).await
|
Self::parallel_race(cache_future, server_future).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_next_up_episodes(
|
async fn get_next_up_episodes(
|
||||||
@@ -754,11 +822,11 @@ impl MediaRepository for HybridRepository {
|
|||||||
let limit_clone = limit;
|
let limit_clone = limit;
|
||||||
|
|
||||||
let cache_future =
|
let cache_future =
|
||||||
self.cache_with_timeout(async move { offline.get_recently_played_audio(limit).await });
|
Self::cache_leg(async move { offline.get_recently_played_audio(limit).await }).await;
|
||||||
|
|
||||||
let server_future = async move { online.get_recently_played_audio(limit_clone).await };
|
let server_future = async move { online.get_recently_played_audio(limit_clone).await };
|
||||||
|
|
||||||
self.parallel_race(cache_future, server_future).await
|
Self::parallel_race(cache_future, server_future).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
@@ -767,11 +835,11 @@ impl MediaRepository for HybridRepository {
|
|||||||
let limit_clone = limit;
|
let limit_clone = limit;
|
||||||
|
|
||||||
let cache_future =
|
let cache_future =
|
||||||
self.cache_with_timeout(async move { offline.get_resume_movies(limit).await });
|
Self::cache_leg(async move { offline.get_resume_movies(limit).await }).await;
|
||||||
|
|
||||||
let server_future = async move { online.get_resume_movies(limit_clone).await };
|
let server_future = async move { online.get_resume_movies(limit_clone).await };
|
||||||
|
|
||||||
self.parallel_race(cache_future, server_future).await
|
Self::parallel_race(cache_future, server_future).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_rediscover_albums(
|
async fn get_rediscover_albums(
|
||||||
@@ -784,11 +852,12 @@ impl MediaRepository for HybridRepository {
|
|||||||
let parent_id_owned = parent_id.map(|s| s.to_string());
|
let parent_id_owned = parent_id.map(|s| s.to_string());
|
||||||
let parent_id_clone = parent_id_owned.clone();
|
let parent_id_clone = parent_id_owned.clone();
|
||||||
|
|
||||||
let cache_future = self.cache_with_timeout(async move {
|
let cache_future = Self::cache_leg(async move {
|
||||||
offline
|
offline
|
||||||
.get_rediscover_albums(parent_id_owned.as_deref(), limit)
|
.get_rediscover_albums(parent_id_owned.as_deref(), limit)
|
||||||
.await
|
.await
|
||||||
});
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
let server_future = async move {
|
let server_future = async move {
|
||||||
online
|
online
|
||||||
@@ -796,7 +865,7 @@ impl MediaRepository for HybridRepository {
|
|||||||
.await
|
.await
|
||||||
};
|
};
|
||||||
|
|
||||||
self.parallel_race(cache_future, server_future).await
|
Self::parallel_race(cache_future, server_future).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
||||||
@@ -869,11 +938,11 @@ impl MediaRepository for HybridRepository {
|
|||||||
let opts_clone = options.clone();
|
let opts_clone = options.clone();
|
||||||
|
|
||||||
let cache_future =
|
let cache_future =
|
||||||
self.cache_with_timeout(async move { offline.search(&query, opts_clone).await });
|
Self::cache_leg(async move { offline.search(&query, opts_clone).await }).await;
|
||||||
|
|
||||||
let server_future = async move { online.search(&query_clone, options).await };
|
let server_future = async move { online.search(&query_clone, options).await };
|
||||||
|
|
||||||
self.parallel_race(cache_future, server_future).await
|
Self::parallel_race(cache_future, server_future).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
|
async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
|
||||||
@@ -1019,11 +1088,11 @@ impl MediaRepository for HybridRepository {
|
|||||||
let person_id_clone = person_id.clone();
|
let person_id_clone = person_id.clone();
|
||||||
|
|
||||||
let cache_future =
|
let cache_future =
|
||||||
self.cache_with_timeout(async move { offline.get_person(&person_id).await });
|
Self::cache_leg(async move { offline.get_person(&person_id).await }).await;
|
||||||
|
|
||||||
let server_future = async move { online.get_person(&person_id_clone).await };
|
let server_future = async move { online.get_person(&person_id_clone).await };
|
||||||
|
|
||||||
self.parallel_race(cache_future, server_future).await
|
Self::parallel_race(cache_future, server_future).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_items_by_person(
|
async fn get_items_by_person(
|
||||||
@@ -1037,14 +1106,16 @@ impl MediaRepository for HybridRepository {
|
|||||||
let person_id_clone = person_id.clone();
|
let person_id_clone = person_id.clone();
|
||||||
let opts_clone = options.clone();
|
let opts_clone = options.clone();
|
||||||
|
|
||||||
let cache_future = self.cache_with_timeout(async move {
|
let cache_future =
|
||||||
offline.get_items_by_person(&person_id, opts_clone).await
|
Self::cache_leg(
|
||||||
});
|
async move { offline.get_items_by_person(&person_id, opts_clone).await },
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let server_future =
|
let server_future =
|
||||||
async move { online.get_items_by_person(&person_id_clone, options).await };
|
async move { online.get_items_by_person(&person_id_clone, options).await };
|
||||||
|
|
||||||
self.parallel_race(cache_future, server_future).await
|
Self::parallel_race(cache_future, server_future).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: UR-067 | DR-115
|
/// TRACES: UR-067 | DR-115
|
||||||
@@ -1092,12 +1163,12 @@ impl MediaRepository for HybridRepository {
|
|||||||
let item_id = item_id.to_string();
|
let item_id = item_id.to_string();
|
||||||
let item_id_clone = item_id.clone();
|
let item_id_clone = item_id.clone();
|
||||||
|
|
||||||
let cache_future = self
|
let cache_future =
|
||||||
.cache_with_timeout(async move { offline.get_similar_items(&item_id, limit).await });
|
Self::cache_leg(async move { offline.get_similar_items(&item_id, limit).await }).await;
|
||||||
|
|
||||||
let server_future = async move { online.get_similar_items(&item_id_clone, limit).await };
|
let server_future = async move { online.get_similar_items(&item_id_clone, limit).await };
|
||||||
|
|
||||||
self.parallel_race(cache_future, server_future).await
|
Self::parallel_race(cache_future, server_future).await
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Playlist Methods =====
|
// ===== Playlist Methods =====
|
||||||
@@ -1228,6 +1299,87 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
/// Offline, a cache read slowed past the fast path must still answer.
|
||||||
|
///
|
||||||
|
/// The database is one SQLite connection behind one mutex, so a concurrent
|
||||||
|
/// write blocks reads for its duration and the 100 ms fast path trips on
|
||||||
|
/// slow storage. The deadline used to *cancel* the read and report it as a
|
||||||
|
/// miss; with the server leg also failing (offline), the user got a network
|
||||||
|
/// error over cached content that was sitting on disk.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-002 | DR-013
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_slow_cache_still_answers_when_the_server_is_gone() {
|
||||||
|
let cache = HybridRepository::cache_leg(async {
|
||||||
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||||
|
Ok(vec![MediaItem {
|
||||||
|
id: "cached-item".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let server = async {
|
||||||
|
Err(RepoError::Network {
|
||||||
|
message: "offline".to_string(),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let got = HybridRepository::parallel_race(cache, server)
|
||||||
|
.await
|
||||||
|
.expect("a slow cache read must still be delivered when the server is gone");
|
||||||
|
assert_eq!(got.len(), 1);
|
||||||
|
assert_eq!(got[0].id, "cached-item");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cache that beats the deadline still short-circuits the server.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-002 | DR-013
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_fast_cache_hit_never_reaches_the_server() {
|
||||||
|
let cache = HybridRepository::cache_leg(async {
|
||||||
|
Ok(vec![MediaItem {
|
||||||
|
id: "fast".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}])
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let server = async {
|
||||||
|
panic!("the server leg must not run on a cache hit");
|
||||||
|
};
|
||||||
|
|
||||||
|
let got = HybridRepository::parallel_race(cache, server)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(got[0].id, "fast");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When both sides fail, the server's error is what the caller sees.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-002 | DR-013
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_failing_slow_cache_reports_the_server_error() {
|
||||||
|
let cache: CacheLeg<Vec<MediaItem>> = HybridRepository::cache_leg(async {
|
||||||
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||||
|
Err(RepoError::Database {
|
||||||
|
message: "disk gone".to_string(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let server = async {
|
||||||
|
Err(RepoError::Network {
|
||||||
|
message: "offline".to_string(),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = HybridRepository::parallel_race(cache, server)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, RepoError::Network { .. }), "got {err:?}");
|
||||||
|
}
|
||||||
|
|
||||||
/// Mock offline repository that tracks queries and saves
|
/// Mock offline repository that tracks queries and saves
|
||||||
struct MockOfflineRepo {
|
struct MockOfflineRepo {
|
||||||
items: Arc<Mutex<Vec<MediaItem>>>,
|
items: Arc<Mutex<Vec<MediaItem>>>,
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
pub mod capabilities;
|
||||||
pub mod device_profile;
|
pub mod device_profile;
|
||||||
|
pub mod endpoints;
|
||||||
/// User-chosen browsing exclusions (UR-076 / DR-209).
|
/// User-chosen browsing exclusions (UR-076 / DR-209).
|
||||||
pub mod exclusions;
|
pub mod exclusions;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod generation_tests;
|
||||||
pub mod hybrid;
|
pub mod hybrid;
|
||||||
pub mod offline;
|
pub mod offline;
|
||||||
pub mod online;
|
pub mod online;
|
||||||
pub mod series_progress;
|
pub mod series_progress;
|
||||||
|
#[cfg(test)]
|
||||||
|
pub mod server_fixture;
|
||||||
/// Backend-owned stream selection (UR-079 / DR-225).
|
/// Backend-owned stream selection (UR-079 / DR-225).
|
||||||
pub mod stream_selection;
|
pub mod stream_selection;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|||||||
@@ -84,6 +84,30 @@ fn build_fts_prefix_query(query: &str) -> Option<String> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The Jellyfin taxonomy half of "does cached item `i` belong to library `l`":
|
||||||
|
/// the library's `collection_type` against the item's `item_type`.
|
||||||
|
///
|
||||||
|
/// A macro rather than a `const` because both callers need it *inside* a larger
|
||||||
|
/// SQL string literal, and `concat!` cannot take a const. One definition, so the
|
||||||
|
/// two sites cannot drift — they did once already, and opening any downloaded
|
||||||
|
/// library then listed every downloaded item on the server (DR-167).
|
||||||
|
///
|
||||||
|
/// Deliberately has **no fall-open arm**. Adding "…or the type is unknown"
|
||||||
|
/// makes the clause true for every row, which is precisely the defect it exists
|
||||||
|
/// to prevent; callers that want that behaviour must say so themselves and
|
||||||
|
/// justify it, as `LIBRARY_HOLDS_ITEM` does.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-007, UR-055 | DR-167, DR-277
|
||||||
|
macro_rules! library_type_matches_item {
|
||||||
|
() => {
|
||||||
|
"(
|
||||||
|
(l.collection_type = 'music' AND i.item_type IN ('MusicAlbum', 'MusicArtist', 'Audio'))
|
||||||
|
OR (l.collection_type = 'movies' AND i.item_type = 'Movie')
|
||||||
|
OR (l.collection_type = 'tvshows' AND i.item_type IN ('Series', 'Season', 'Episode'))
|
||||||
|
)"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
pub struct OfflineRepository {
|
pub struct OfflineRepository {
|
||||||
db_service: Arc<RusqliteService>,
|
db_service: Arc<RusqliteService>,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
@@ -422,12 +446,81 @@ impl OfflineRepository {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which library the children of `parent_id` belong to.
|
||||||
|
///
|
||||||
|
/// `Some(parent_id)` when the parent is itself a library, otherwise the
|
||||||
|
/// library the parent item was already filed under — so the association
|
||||||
|
/// propagates down a hierarchy as it is browsed, without needing the server
|
||||||
|
/// to repeat it on every item. `None` for a parent that is neither, which
|
||||||
|
/// is how synthetic parents like "favorites" avoid being filed anywhere.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-007 | DR-278
|
||||||
|
async fn resolve_owning_library(&self, parent_id: &str) -> Option<String> {
|
||||||
|
let is_library: Option<String> = self
|
||||||
|
.db_service
|
||||||
|
.query_optional(
|
||||||
|
Query::with_params(
|
||||||
|
"SELECT id FROM libraries WHERE id = ? AND server_id = ?",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(parent_id.to_string()),
|
||||||
|
QueryParam::String(self.server_id.clone()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
if is_library.is_some() {
|
||||||
|
return is_library;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.db_service
|
||||||
|
.query_optional(
|
||||||
|
Query::with_params(
|
||||||
|
"SELECT library_id FROM items WHERE id = ? AND library_id IS NOT NULL",
|
||||||
|
vec![QueryParam::String(parent_id.to_string())],
|
||||||
|
),
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
async fn save_to_cache_impl(
|
async fn save_to_cache_impl(
|
||||||
&self,
|
&self,
|
||||||
parent_id: &str,
|
parent_id: &str,
|
||||||
items: &[MediaItem],
|
items: &[MediaItem],
|
||||||
now: &str,
|
now: &str,
|
||||||
) -> Result<usize, RepoError> {
|
) -> Result<usize, RepoError> {
|
||||||
|
// Which library do these items belong to?
|
||||||
|
//
|
||||||
|
// Resolved once per call, from the parent being browsed. Two cases and
|
||||||
|
// nothing else:
|
||||||
|
//
|
||||||
|
// * the parent IS a library -> these are its direct children
|
||||||
|
// * the parent is an item -> inherit whatever library that item is
|
||||||
|
// already known to belong to, so tracks
|
||||||
|
// under an album and episodes under a
|
||||||
|
// season land in the same library as
|
||||||
|
// their container
|
||||||
|
//
|
||||||
|
// Synthetic parents ("favorites" and friends) match neither and stay
|
||||||
|
// NULL, which is correct: they are not a library and their contents
|
||||||
|
// span several.
|
||||||
|
//
|
||||||
|
// Until this existed, `library_id` was bound NULL for every cached row
|
||||||
|
// and the only way to associate an item with a library was the
|
||||||
|
// `collection_type` ↔ `item_type` taxonomy. That cannot tell two
|
||||||
|
// libraries of the *same* type apart — a server with "TV" and "Shows"
|
||||||
|
// served both the same contents — and has nothing to say about a
|
||||||
|
// library whose type it does not map (DR-278).
|
||||||
|
//
|
||||||
|
// TRACES: UR-007 | DR-278
|
||||||
|
let owning_library = self.resolve_owning_library(parent_id).await;
|
||||||
|
|
||||||
// Collect all unique parent IDs referenced by items being saved
|
// Collect all unique parent IDs referenced by items being saved
|
||||||
let mut parent_ids = std::collections::HashSet::new();
|
let mut parent_ids = std::collections::HashSet::new();
|
||||||
parent_ids.insert(parent_id.to_string());
|
parent_ids.insert(parent_id.to_string());
|
||||||
@@ -554,8 +647,12 @@ impl OfflineRepository {
|
|||||||
vec![
|
vec![
|
||||||
QueryParam::String(item.id.clone()),
|
QueryParam::String(item.id.clone()),
|
||||||
QueryParam::String(self.server_id.clone()),
|
QueryParam::String(self.server_id.clone()),
|
||||||
// Library is NULL for cached items (may not be synced yet)
|
// The library this browse belongs to; NULL only for
|
||||||
QueryParam::Null, // library_id
|
// synthetic parents. See `resolve_owning_library`.
|
||||||
|
match &owning_library {
|
||||||
|
Some(lib) => QueryParam::String(lib.clone()),
|
||||||
|
None => QueryParam::Null,
|
||||||
|
}, // library_id
|
||||||
// Use the item's actual parent_id, not the function parameter
|
// Use the item's actual parent_id, not the function parameter
|
||||||
match &item.parent_id {
|
match &item.parent_id {
|
||||||
Some(pid) => QueryParam::String(pid.clone()),
|
Some(pid) => QueryParam::String(pid.clone()),
|
||||||
@@ -856,13 +953,13 @@ impl OfflineRepository {
|
|||||||
/// no mapping to narrow it by and hiding its contents would be worse.
|
/// no mapping to narrow it by and hiding its contents would be worse.
|
||||||
///
|
///
|
||||||
/// TRACES: UR-055 | DR-082, DR-167
|
/// TRACES: UR-055 | DR-082, DR-167
|
||||||
const LIBRARY_HOLDS_ITEM: &'static str = "(
|
const LIBRARY_HOLDS_ITEM: &'static str = concat!(
|
||||||
(l.collection_type = 'music' AND i.item_type IN ('MusicAlbum', 'MusicArtist', 'Audio'))
|
"(",
|
||||||
OR (l.collection_type = 'movies' AND i.item_type = 'Movie')
|
library_type_matches_item!(),
|
||||||
OR (l.collection_type = 'tvshows' AND i.item_type IN ('Series', 'Season', 'Episode'))
|
" OR l.collection_type IS NULL
|
||||||
OR l.collection_type IS NULL
|
|
||||||
OR l.collection_type NOT IN ('music', 'movies', 'tvshows')
|
OR l.collection_type NOT IN ('music', 'movies', 'tvshows')
|
||||||
)";
|
)"
|
||||||
|
);
|
||||||
|
|
||||||
/// TRACES: UR-055 | DR-082, DR-083
|
/// TRACES: UR-055 | DR-082, DR-083
|
||||||
const DOWNLOADED_ITEMS_CTE: &'static str = "
|
const DOWNLOADED_ITEMS_CTE: &'static str = "
|
||||||
@@ -1358,14 +1455,43 @@ impl MediaRepository for OfflineRepository {
|
|||||||
-- so match every item on the server and let the type filter
|
-- so match every item on the server and let the type filter
|
||||||
-- (e.g. MusicAlbum / Movie / Series) narrow it. This is what
|
-- (e.g. MusicAlbum / Movie / Series) narrow it. This is what
|
||||||
-- makes library landing pages show albums/movies/shows offline.
|
-- makes library landing pages show albums/movies/shows offline.
|
||||||
|
--
|
||||||
|
-- The type correlation is NOT optional. Without it this
|
||||||
|
-- EXISTS never mentions the item, so it is true for every
|
||||||
|
-- cached row as soon as the requested parent is any library.
|
||||||
|
-- Music/Movies/TV got away with that because their landing
|
||||||
|
-- pages pass `include_item_types`, which narrowed the result;
|
||||||
|
-- the generic library page passes none, so a Books or Photos
|
||||||
|
-- library served the entire cached server (DR-277).
|
||||||
|
--
|
||||||
|
-- `library_id` wins wherever it survived the cache write:
|
||||||
|
-- it is the server's own answer, and it is the only thing
|
||||||
|
-- that can scope a library whose type has no mapping (Books,
|
||||||
|
-- Photos, Collections) or none at all (a mixed library, where
|
||||||
|
-- Jellyfin sends CollectionType null). The taxonomy is the
|
||||||
|
-- fallback for rows that predate it being stored.
|
||||||
|
--
|
||||||
|
-- A library with neither a stored link nor a mapped type now
|
||||||
|
-- matches nothing here and falls through to the server, which
|
||||||
|
-- does know what is in it. Showing nothing briefly beats
|
||||||
|
-- showing somebody else's films with confidence.
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
SELECT 1 FROM libraries l
|
SELECT 1 FROM libraries l
|
||||||
WHERE l.id = ? AND l.server_id = i.server_id
|
WHERE l.id = ? AND l.server_id = i.server_id
|
||||||
|
AND (
|
||||||
|
i.library_id = l.id
|
||||||
|
OR (i.library_id IS NULL AND {})
|
||||||
|
)
|
||||||
)
|
)
|
||||||
){}{}
|
){}{}
|
||||||
ORDER BY {}
|
ORDER BY {}
|
||||||
LIMIT {} OFFSET {}",
|
LIMIT {} OFFSET {}",
|
||||||
type_filter, favorites_filter, order_by, limit, start_index
|
library_type_matches_item!(),
|
||||||
|
type_filter,
|
||||||
|
favorites_filter,
|
||||||
|
order_by,
|
||||||
|
limit,
|
||||||
|
start_index
|
||||||
);
|
);
|
||||||
|
|
||||||
// The requested id is compared against every hierarchy-linkage column
|
// The requested id is compared against every hierarchy-linkage column
|
||||||
@@ -4587,6 +4713,27 @@ mod tests {
|
|||||||
|
|
||||||
let db_service = create_test_db();
|
let db_service = create_test_db();
|
||||||
seed_favorites(&db_service).await;
|
seed_favorites(&db_service).await;
|
||||||
|
|
||||||
|
// A favourite album *in lib-1*. The fixture's `album-fav` lives in
|
||||||
|
// lib-2, and this test used to expect it back from a lib-1 listing —
|
||||||
|
// which only held because the library clause matched every cached row
|
||||||
|
// regardless of which library it was in (DR-277). The assertions below
|
||||||
|
// still span both requested types, which is what UT-206 is really about;
|
||||||
|
// they now do it with an album that is actually in the library.
|
||||||
|
db_service
|
||||||
|
.execute(Query::new(
|
||||||
|
"INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
|
||||||
|
VALUES ('album-lib1', 'test-server', 'Album In Lib One', 'MusicAlbum', 'lib-1', '2026-01-01', 'Album In Lib One')",
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
db_service
|
||||||
|
.execute(Query::new(
|
||||||
|
"INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'album-lib1', 1)",
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let repo = OfflineRepository::new(
|
let repo = OfflineRepository::new(
|
||||||
db_service,
|
db_service,
|
||||||
"test-server".to_string(),
|
"test-server".to_string(),
|
||||||
@@ -4605,7 +4752,11 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let mut ids: Vec<&str> = both.items.iter().map(|i| i.id.as_str()).collect();
|
let mut ids: Vec<&str> = both.items.iter().map(|i| i.id.as_str()).collect();
|
||||||
ids.sort();
|
ids.sort();
|
||||||
assert_eq!(ids, vec!["album-fav", "movie-fav", "movie-plain"]);
|
assert_eq!(ids, vec!["album-lib1", "movie-fav", "movie-plain"]);
|
||||||
|
assert!(
|
||||||
|
!ids.contains(&"album-fav"),
|
||||||
|
"album-fav belongs to lib-2 and must not appear in a lib-1 listing"
|
||||||
|
);
|
||||||
|
|
||||||
// Two type placeholders *and* the favourites parameter after them.
|
// Two type placeholders *and* the favourites parameter after them.
|
||||||
let favourites = repo
|
let favourites = repo
|
||||||
@@ -4621,7 +4772,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let mut ids: Vec<&str> = favourites.items.iter().map(|i| i.id.as_str()).collect();
|
let mut ids: Vec<&str> = favourites.items.iter().map(|i| i.id.as_str()).collect();
|
||||||
ids.sort();
|
ids.sort();
|
||||||
assert_eq!(ids, vec!["album-fav", "movie-fav"]);
|
assert_eq!(ids, vec!["album-lib1", "movie-fav"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// UT-102 — caching a server result mirrors its favourite state locally,
|
/// UT-102 — caching a server result mirrors its favourite state locally,
|
||||||
@@ -4937,4 +5088,215 @@ mod tests {
|
|||||||
"a position with no favourite flag must still be mirrored"
|
"a position with no favourite flag must still be mirrored"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A library whose `collection_type` is not one of the three the app has
|
||||||
|
/// landing pages for — Books, Photos, Home Videos, Collections, or a mixed
|
||||||
|
/// library — must not show the entire server.
|
||||||
|
///
|
||||||
|
/// The cache has no item→library link at all (`library_id`/`parent_id` are
|
||||||
|
/// NULL, see [[offline-libraries-never-cached]]), so `get_items` matched a
|
||||||
|
/// library parent with an EXISTS that never referenced the item:
|
||||||
|
///
|
||||||
|
/// OR EXISTS (SELECT 1 FROM libraries l WHERE l.id = ? AND ...)
|
||||||
|
///
|
||||||
|
/// True for every cached row the moment the requested id is any library.
|
||||||
|
/// The music/movies/TV landing pages got away with it because each passes
|
||||||
|
/// `include_item_types`, which narrowed the result; the generic library page
|
||||||
|
/// passes none, so opening a Books library served whatever happened to be
|
||||||
|
/// cached — films, albums, episodes. Same defect the downloaded listing had
|
||||||
|
/// in DR-167, in the path nobody re-checked.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-007, UR-055 | DR-277 | UT-247
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_items_unknown_library_type_does_not_return_whole_server() {
|
||||||
|
// Shared global: other tests flip it, so hold the lock and
|
||||||
|
// state it explicitly rather than inheriting whatever ran last.
|
||||||
|
let _guard = lock_catalog_browse();
|
||||||
|
set_include_catalog_browse(true);
|
||||||
|
|
||||||
|
let db = create_test_db();
|
||||||
|
|
||||||
|
insert_item(&db, "movie-1", "Movie", None, None, None).await;
|
||||||
|
insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
|
||||||
|
insert_item(&db, "series-1", "Series", None, None, None).await;
|
||||||
|
|
||||||
|
// Every library kind the app has no landing page for, including the
|
||||||
|
// empty `collection_type` Jellyfin sends for a mixed library.
|
||||||
|
for collection_type in ["books", "boxsets", "photos", "homevideos", ""] {
|
||||||
|
let lib = format!("lib-{collection_type}");
|
||||||
|
seed_library(&db, &lib, collection_type).await;
|
||||||
|
|
||||||
|
let repo = make_repo(&db);
|
||||||
|
let ids: Vec<String> = repo
|
||||||
|
.get_items(&lib, None)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|i| i.id.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
ids.is_empty(),
|
||||||
|
"a '{collection_type}' library must not serve the server's films, \
|
||||||
|
albums and shows; got {:?}",
|
||||||
|
ids
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two libraries of the *same* type are still two libraries. A server with
|
||||||
|
/// "TV" and "Shows" — or "Films" and "Kids Films" — must not serve both the
|
||||||
|
/// same contents.
|
||||||
|
///
|
||||||
|
/// The taxonomy fallback cannot tell them apart: it matches on
|
||||||
|
/// `collection_type`, which is identical for both, so every Series on the
|
||||||
|
/// server satisfies either one. Only the stored `library_id` can separate
|
||||||
|
/// them, which is why populating it is the real fix rather than a nicety.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-007 | DR-277 | UT-250
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_items_two_libraries_of_one_type_are_not_interchangeable() {
|
||||||
|
// Shared global: other tests flip it, so hold the lock and
|
||||||
|
// state it explicitly rather than inheriting whatever ran last.
|
||||||
|
let _guard = lock_catalog_browse();
|
||||||
|
set_include_catalog_browse(true);
|
||||||
|
|
||||||
|
let db = create_test_db();
|
||||||
|
seed_library(&db, "tv-lib", "tvshows").await;
|
||||||
|
seed_library(&db, "shows-lib", "tvshows").await;
|
||||||
|
|
||||||
|
let repo = make_repo(&db);
|
||||||
|
|
||||||
|
// Seeded through the real write path, because that is what the fix
|
||||||
|
// changes: browsing a library is what files its contents under it.
|
||||||
|
for (id, lib) in [("series-a", "tv-lib"), ("series-b", "shows-lib")] {
|
||||||
|
let mut item = create_test_item(id, id, None);
|
||||||
|
item.item_type = "Series".to_string();
|
||||||
|
item.kind = crate::domain::MediaKind::Series;
|
||||||
|
repo.save_to_cache(lib, &[item]).await.unwrap();
|
||||||
|
}
|
||||||
|
for (lib, own, other) in [
|
||||||
|
("tv-lib", "series-a", "series-b"),
|
||||||
|
("shows-lib", "series-b", "series-a"),
|
||||||
|
] {
|
||||||
|
let ids: Vec<String> = repo
|
||||||
|
.get_items(lib, None)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|i| i.id.clone())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
ids.contains(&own.to_string()),
|
||||||
|
"{lib} should list {own}; got {:?}",
|
||||||
|
ids
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!ids.contains(&other.to_string()),
|
||||||
|
"{lib} must not list {other}, which lives in the other library; got {:?}",
|
||||||
|
ids
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opening an individual collection is a different path and must keep
|
||||||
|
/// working: a BoxSet's children carry `parent_id`, which the cache does
|
||||||
|
/// store, so they are matched by the ordinary parent link rather than by
|
||||||
|
/// the library clause this fix narrowed.
|
||||||
|
///
|
||||||
|
/// Worth pinning separately — narrowing the library clause could plausibly
|
||||||
|
/// have taken collections with it, and "Collections is empty" would look
|
||||||
|
/// identical to the bug it was meant to fix.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-007 | DR-277 | UT-249
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_items_collection_lists_its_own_children() {
|
||||||
|
// Shared global: other tests flip it, so hold the lock and
|
||||||
|
// state it explicitly rather than inheriting whatever ran last.
|
||||||
|
let _guard = lock_catalog_browse();
|
||||||
|
set_include_catalog_browse(true);
|
||||||
|
|
||||||
|
let db = create_test_db();
|
||||||
|
seed_library(&db, "boxset-lib", "boxsets").await;
|
||||||
|
|
||||||
|
insert_item(&db, "boxset-1", "BoxSet", None, None, None).await;
|
||||||
|
insert_item(&db, "outsider", "Movie", None, None, None).await;
|
||||||
|
|
||||||
|
// A film inside the collection: linked by parent_id, which is what a
|
||||||
|
// BoxSet's children actually carry.
|
||||||
|
db.execute(Query::with_params(
|
||||||
|
"INSERT INTO items (id, server_id, name, item_type, parent_id, synced_at) \
|
||||||
|
VALUES ('in-set', 'test-server', 'In The Set', 'Movie', ?1, '2024-01-01')",
|
||||||
|
vec![QueryParam::String("boxset-1".to_string())],
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let repo = make_repo(&db);
|
||||||
|
let ids: Vec<String> = repo
|
||||||
|
.get_items("boxset-1", None)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|i| i.id.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ids,
|
||||||
|
vec!["in-set".to_string()],
|
||||||
|
"a collection lists its own children and nothing else; got {:?}",
|
||||||
|
ids
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The narrowing must not break the libraries that *do* have landing pages:
|
||||||
|
/// they reach the same query and must keep returning their own media.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-007 | DR-277 | UT-248
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_items_typed_libraries_still_return_their_own_media() {
|
||||||
|
// Shared global: other tests flip it, so hold the lock and
|
||||||
|
// state it explicitly rather than inheriting whatever ran last.
|
||||||
|
let _guard = lock_catalog_browse();
|
||||||
|
set_include_catalog_browse(true);
|
||||||
|
|
||||||
|
let db = create_test_db();
|
||||||
|
seed_library(&db, "music-lib", "music").await;
|
||||||
|
seed_library(&db, "movie-lib", "movies").await;
|
||||||
|
seed_library(&db, "tv-lib", "tvshows").await;
|
||||||
|
|
||||||
|
insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
|
||||||
|
insert_item(&db, "movie-1", "Movie", None, None, None).await;
|
||||||
|
insert_item(&db, "series-1", "Series", None, None, None).await;
|
||||||
|
|
||||||
|
let repo = make_repo(&db);
|
||||||
|
|
||||||
|
for (lib, expected, forbidden) in [
|
||||||
|
("music-lib", "album-1", "movie-1"),
|
||||||
|
("movie-lib", "movie-1", "album-1"),
|
||||||
|
("tv-lib", "series-1", "album-1"),
|
||||||
|
] {
|
||||||
|
let ids: Vec<String> = repo
|
||||||
|
.get_items(lib, None)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|i| i.id.clone())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
ids.contains(&expected.to_string()),
|
||||||
|
"{lib} should list {expected}; got {:?}",
|
||||||
|
ids
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!ids.contains(&forbidden.to_string()),
|
||||||
|
"{lib} must not list {forbidden}; got {:?}",
|
||||||
|
ids
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+172
-325
@@ -5,6 +5,8 @@ use log::{debug, error, info, warn};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
|
use super::capabilities::ServerCapabilities;
|
||||||
|
use super::endpoints;
|
||||||
use super::stream_selection::{
|
use super::stream_selection::{
|
||||||
quality_options_for_source, PlaybackKind, Rendition, StreamSelection, Transport,
|
quality_options_for_source, PlaybackKind, Rendition, StreamSelection, Transport,
|
||||||
};
|
};
|
||||||
@@ -188,6 +190,12 @@ pub struct OnlineRepository {
|
|||||||
/// This is the source of truth for the offline/online banner. `None` in
|
/// This is the source of truth for the offline/online banner. `None` in
|
||||||
/// tests / contexts where connectivity tracking isn't wired up.
|
/// tests / contexts where connectivity tracking isn't wired up.
|
||||||
connectivity: Option<ConnectivityReporter>,
|
connectivity: Option<ConnectivityReporter>,
|
||||||
|
/// What this server can do, resolved once from the version it reported at
|
||||||
|
/// connect. Every route and every version-dependent decision reads a named
|
||||||
|
/// flag from here; nothing compares a version number.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | IR-035, DR-280
|
||||||
|
capabilities: ServerCapabilities,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OnlineRepository {
|
impl OnlineRepository {
|
||||||
@@ -209,9 +217,34 @@ impl OnlineRepository {
|
|||||||
user_id,
|
user_id,
|
||||||
access_token,
|
access_token,
|
||||||
connectivity: None,
|
connectivity: None,
|
||||||
|
// Assumed until the caller supplies what the server reported. The
|
||||||
|
// assumption is the current target, which is what it will be in
|
||||||
|
// nearly every case.
|
||||||
|
capabilities: ServerCapabilities::assumed(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adopt the capabilities resolved from the version the server reported at
|
||||||
|
/// connect. Without this the repository assumes the current target.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | IR-035, DR-280
|
||||||
|
pub fn with_capabilities(mut self, capabilities: ServerCapabilities) -> Self {
|
||||||
|
self.capabilities = capabilities;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the server on the other end can do.
|
||||||
|
///
|
||||||
|
/// Test-only: production reads the flags through the route table and the
|
||||||
|
/// playback paths rather than asking the repository for them, so exposing
|
||||||
|
/// this outside tests would be an accessor nobody calls.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-280
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn capabilities(&self) -> &ServerCapabilities {
|
||||||
|
&self.capabilities
|
||||||
|
}
|
||||||
|
|
||||||
/// Attach a connectivity reporter so server outcomes drive the reachability
|
/// Attach a connectivity reporter so server outcomes drive the reachability
|
||||||
/// state observed by the UI. See `report_outcome`.
|
/// state observed by the UI. See `report_outcome`.
|
||||||
pub fn with_connectivity(mut self, reporter: ConnectivityReporter) -> Self {
|
pub fn with_connectivity(mut self, reporter: ConnectivityReporter) -> Self {
|
||||||
@@ -261,7 +294,7 @@ impl OnlineRepository {
|
|||||||
.http_client
|
.http_client
|
||||||
.client
|
.client
|
||||||
.get(url)
|
.get(url)
|
||||||
.header("X-Emby-Authorization", self.auth_header())
|
.header("Authorization", self.auth_header())
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||||
|
|
||||||
@@ -298,11 +331,7 @@ impl OnlineRepository {
|
|||||||
item_id: &str,
|
item_id: &str,
|
||||||
t: f64,
|
t: f64,
|
||||||
) -> Result<Vec<JRayActor>, RepoError> {
|
) -> Result<Vec<JRayActor>, RepoError> {
|
||||||
let endpoint = format!(
|
let endpoint = endpoints::jray_context(&self.capabilities, item_id, t);
|
||||||
"/Plugins/JRay/Items/{}/jray?t={}",
|
|
||||||
urlencoding::encode(item_id),
|
|
||||||
t
|
|
||||||
);
|
|
||||||
match self.get_json::<JRayContext>(&endpoint).await {
|
match self.get_json::<JRayContext>(&endpoint).await {
|
||||||
Ok(context) => Ok(context.actors),
|
Ok(context) => Ok(context.actors),
|
||||||
// No plugin / no truth data for this item — not an error to the user.
|
// No plugin / no truth data for this item — not an error to the user.
|
||||||
@@ -339,7 +368,7 @@ impl OnlineRepository {
|
|||||||
.http_client
|
.http_client
|
||||||
.client
|
.client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.header("X-Emby-Authorization", self.auth_header())
|
.header("Authorization", self.auth_header())
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| RepoError::Network {
|
.map_err(|e| RepoError::Network {
|
||||||
message: format!("Failed to build request: {}", e),
|
message: format!("Failed to build request: {}", e),
|
||||||
@@ -414,7 +443,7 @@ impl OnlineRepository {
|
|||||||
.client
|
.client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("X-Emby-Authorization", self.auth_header())
|
.header("Authorization", self.auth_header())
|
||||||
.json(body)
|
.json(body)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| RepoError::Network {
|
.map_err(|e| RepoError::Network {
|
||||||
@@ -474,7 +503,7 @@ impl OnlineRepository {
|
|||||||
.client
|
.client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("X-Emby-Authorization", self.auth_header())
|
.header("Authorization", self.auth_header())
|
||||||
.json(body)
|
.json(body)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| RepoError::Network {
|
.map_err(|e| RepoError::Network {
|
||||||
@@ -538,7 +567,7 @@ impl OnlineRepository {
|
|||||||
.http_client
|
.http_client
|
||||||
.client
|
.client
|
||||||
.delete(&url)
|
.delete(&url)
|
||||||
.header("X-Emby-Authorization", self.auth_header())
|
.header("Authorization", self.auth_header())
|
||||||
.send();
|
.send();
|
||||||
|
|
||||||
match request.await {
|
match request.await {
|
||||||
@@ -630,7 +659,7 @@ impl OnlineRepository {
|
|||||||
// TRACES: UR-004, UR-080 | DR-234
|
// TRACES: UR-004, UR-080 | DR-234
|
||||||
let (renderer_video_codecs, _) = super::device_profile::renderer_codecs();
|
let (renderer_video_codecs, _) = super::device_profile::renderer_codecs();
|
||||||
let mut params = vec![
|
let mut params = vec![
|
||||||
("api_key", self.access_token.clone()),
|
("ApiKey", self.access_token.clone()),
|
||||||
("DeviceId", DEVICE_ID.to_string()),
|
("DeviceId", DEVICE_ID.to_string()),
|
||||||
("PlaySessionId", play_session_id),
|
("PlaySessionId", play_session_id),
|
||||||
("VideoCodec", renderer_video_codecs),
|
("VideoCodec", renderer_video_codecs),
|
||||||
@@ -724,7 +753,7 @@ impl OnlineRepository {
|
|||||||
) -> Result<String, RepoError> {
|
) -> Result<String, RepoError> {
|
||||||
let mut params = vec![
|
let mut params = vec![
|
||||||
("UserId", self.user_id.clone()),
|
("UserId", self.user_id.clone()),
|
||||||
("api_key", self.access_token.clone()),
|
("ApiKey", self.access_token.clone()),
|
||||||
("DeviceId", DEVICE_ID.to_string()),
|
("DeviceId", DEVICE_ID.to_string()),
|
||||||
// Progressive mp3 over HTTP — ExoPlayer-friendly; no HLS/ts.
|
// Progressive mp3 over HTTP — ExoPlayer-friendly; no HLS/ts.
|
||||||
("Container", "mp3".to_string()),
|
("Container", "mp3".to_string()),
|
||||||
@@ -783,7 +812,7 @@ impl OnlineRepository {
|
|||||||
&self,
|
&self,
|
||||||
item_id: &str,
|
item_id: &str,
|
||||||
) -> Result<(NegotiatedSource, String), RepoError> {
|
) -> Result<(NegotiatedSource, String), RepoError> {
|
||||||
let endpoint = format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id));
|
let endpoint = endpoints::playback_info(&self.capabilities, item_id);
|
||||||
|
|
||||||
// What the renderer that will decode this can play. One source, shared
|
// What the renderer that will decode this can play. One source, shared
|
||||||
// with the transcode URL builder and the client-side audio override, so
|
// with the transcode URL builder and the client-side audio override, so
|
||||||
@@ -1043,7 +1072,7 @@ impl OnlineRepository {
|
|||||||
// (which is the *video* stream — the index is global across all
|
// (which is the *video* stream — the index is global across all
|
||||||
// streams) only misleads servers that do honour it.
|
// streams) only misleads servers that do honour it.
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId={}&api_key={}&userId={}",
|
"{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId={}&ApiKey={}&userId={}",
|
||||||
self.server_url,
|
self.server_url,
|
||||||
item_id,
|
item_id,
|
||||||
effective_source_id,
|
effective_source_id,
|
||||||
@@ -1191,119 +1220,26 @@ impl From<JellyfinUserData> for UserData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the Jellyfin endpoint for a folder listing.
|
/// Test-only shim over [`endpoints::get_items`].
|
||||||
///
|
///
|
||||||
/// Extracted from `get_items` so the query it produces — in particular the
|
/// The endpoint builders moved to `endpoints.rs` under DR-279. These wrappers
|
||||||
/// favourites filter — can be asserted without standing up an HTTP server.
|
/// keep the existing requirement coverage (DR-116, DR-212, DR-257 and friends)
|
||||||
///
|
/// pointed at the production path rather than deleting it, and pin the *default*
|
||||||
/// TRACES: UR-007, UR-067 | DR-116 | UT-104
|
/// capability shape — the URLs that shipped before the route table existed.
|
||||||
|
#[cfg(test)]
|
||||||
fn build_get_items_endpoint(
|
fn build_get_items_endpoint(
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
parent_id: &str,
|
parent_id: &str,
|
||||||
options: Option<&GetItemsOptions>,
|
options: Option<&GetItemsOptions>,
|
||||||
) -> String {
|
) -> String {
|
||||||
// Every value below is percent-encoded before it goes into the query
|
endpoints::get_items(&ServerCapabilities::assumed(), user_id, parent_id, options)
|
||||||
// string, the same way `Genres` and `SearchTerm` already are: these are
|
|
||||||
// values, not URL syntax, so a space or an `&` in one must not split it
|
|
||||||
// into another parameter.
|
|
||||||
//
|
|
||||||
// TRACES: UR-007 | DR-212 | UT-206
|
|
||||||
let mut endpoint = format!(
|
|
||||||
"/Users/{}/Items?ParentId={}",
|
|
||||||
user_id,
|
|
||||||
urlencoding::encode(parent_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(opts) = options {
|
|
||||||
if let Some(limit) = opts.limit {
|
|
||||||
endpoint.push_str(&format!("&Limit={}", limit));
|
|
||||||
}
|
|
||||||
if let Some(start_index) = opts.start_index {
|
|
||||||
endpoint.push_str(&format!("&StartIndex={}", start_index));
|
|
||||||
}
|
|
||||||
if let Some(types) = &opts.include_item_types {
|
|
||||||
// Encode each type, not the joined string: the comma is the
|
|
||||||
// list separator Jellyfin splits on.
|
|
||||||
let encoded: Vec<String> = types
|
|
||||||
.iter()
|
|
||||||
.map(|t| urlencoding::encode(t).into_owned())
|
|
||||||
.collect();
|
|
||||||
endpoint.push_str(&format!("&IncludeItemTypes={}", encoded.join(",")));
|
|
||||||
}
|
|
||||||
// An explicit sort always wins; the container's default only fills the
|
|
||||||
// gap when the caller named none. A caller that names neither gets no
|
|
||||||
// SortBy at all, leaving the server's own order intact.
|
|
||||||
//
|
|
||||||
// TRACES: UR-007 | DR-257 | UT-229
|
|
||||||
let default_sort = default_listing_sort(opts.parent_kind);
|
|
||||||
let sort_by = opts
|
|
||||||
.sort_by
|
|
||||||
.as_deref()
|
|
||||||
.or(default_sort.map(|(field, _)| field));
|
|
||||||
let sort_order = opts
|
|
||||||
.sort_order
|
|
||||||
.as_deref()
|
|
||||||
.or(default_sort.map(|(_, order)| order));
|
|
||||||
|
|
||||||
if let Some(sort_by) = sort_by {
|
|
||||||
// SortBy is likewise a comma-delimited list (`hybrid.rs` sends
|
|
||||||
// "ParentIndexNumber,IndexNumber,SortName"), so encode per field.
|
|
||||||
let encoded: Vec<String> = sort_by
|
|
||||||
.split(',')
|
|
||||||
.map(|field| urlencoding::encode(field).into_owned())
|
|
||||||
.collect();
|
|
||||||
endpoint.push_str(&format!("&SortBy={}", encoded.join(",")));
|
|
||||||
}
|
|
||||||
if let Some(sort_order) = sort_order {
|
|
||||||
endpoint.push_str(&format!("&SortOrder={}", urlencoding::encode(sort_order)));
|
|
||||||
}
|
|
||||||
if let Some(recursive) = opts.recursive {
|
|
||||||
endpoint.push_str(&format!("&Recursive={}", recursive));
|
|
||||||
}
|
|
||||||
if let Some(genres) = &opts.genres {
|
|
||||||
if !genres.is_empty() {
|
|
||||||
// Genre names may contain spaces/ampersands, so percent-encode each.
|
|
||||||
let encoded: Vec<String> = genres
|
|
||||||
.iter()
|
|
||||||
.map(|g| urlencoding::encode(g).into_owned())
|
|
||||||
.collect();
|
|
||||||
endpoint.push_str(&format!("&Genres={}", encoded.join("|")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// TRACES: UR-067 | DR-116 | UT-104
|
|
||||||
if opts.favorites_only == Some(true) {
|
|
||||||
endpoint.push_str("&Filters=IsFavorite");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request image fields for list views (People only needed in get_item
|
/// Test-only shim over [`endpoints::latest_items`]. See
|
||||||
// detail view). Genres is needed so cached items carry their genres,
|
/// [`build_get_items_endpoint`].
|
||||||
// which lets the offline store derive genre lists + per-genre counts.
|
#[cfg(test)]
|
||||||
endpoint
|
|
||||||
.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
|
|
||||||
endpoint
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the Jellyfin endpoint for a "recently added" listing.
|
|
||||||
///
|
|
||||||
/// `GroupItems=true` is the load-bearing parameter: Jellyfin defaults it to
|
|
||||||
/// `false`, which returns each newly-added *leaf* separately, so importing one
|
|
||||||
/// 14-track album pushed 14 rows into "recently added" and buried everything
|
|
||||||
/// else. With grouping on, the server collapses children into the container
|
|
||||||
/// that was added — an album appears once, while movies (which have no such
|
|
||||||
/// container) are unaffected.
|
|
||||||
///
|
|
||||||
/// Pulled out of `get_latest_items` so the query can be asserted without an
|
|
||||||
/// HTTP server, matching `build_favorites_endpoint`.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-024, UR-034 | IR-024, JA-016
|
|
||||||
fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usize>) -> String {
|
fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usize>) -> String {
|
||||||
format!(
|
endpoints::latest_items(&ServerCapabilities::assumed(), user_id, parent_id, limit)
|
||||||
"/Users/{}/Items/Latest?ParentId={}&Limit={}&GroupItems=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
|
||||||
user_id,
|
|
||||||
parent_id,
|
|
||||||
limit.unwrap_or(16)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many rows to ask the server for, given how many the row will show.
|
/// How many rows to ask the server for, given how many the row will show.
|
||||||
@@ -1417,73 +1353,21 @@ fn album_from_track(track: &MediaItem, album_id: String) -> MediaItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the Jellyfin endpoint for a Next Up listing.
|
/// Test-only shim over [`endpoints::next_up`]. See [`build_get_items_endpoint`].
|
||||||
///
|
#[cfg(test)]
|
||||||
/// `EnableResumable=false` is the point of this query: the server default is
|
|
||||||
/// `true`, which makes a partially-watched episode its own series' "next up" —
|
|
||||||
/// the very episode `/Items/Resume` returns — so Continue Watching and Next Up
|
|
||||||
/// end up showing the same cards. Next Up should only ever offer episodes the
|
|
||||||
/// viewer has not started. Servers predating the parameter ignore it, which is
|
|
||||||
/// why the frontend also drops in-progress entries (DR-197).
|
|
||||||
///
|
|
||||||
/// Pulled out of `get_next_up_episodes` so the query can be asserted without an
|
|
||||||
/// HTTP server, matching `build_favorites_endpoint`.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-023, UR-059 | DR-197, JA-014, JA-036 | UT-190, UT-191
|
|
||||||
fn build_next_up_endpoint(user_id: &str, series_id: Option<&str>, limit: Option<usize>) -> String {
|
fn build_next_up_endpoint(user_id: &str, series_id: Option<&str>, limit: Option<usize>) -> String {
|
||||||
let mut endpoint = format!(
|
endpoints::next_up(&ServerCapabilities::assumed(), user_id, series_id, limit)
|
||||||
"/Shows/NextUp?UserId={}&Limit={}&EnableResumable=false&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
|
||||||
user_id,
|
|
||||||
limit.unwrap_or(16)
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(sid) = series_id {
|
|
||||||
endpoint.push_str(&format!("&SeriesId={}", sid));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint
|
/// Test-only shim over [`endpoints::favorites`]. See
|
||||||
}
|
/// [`build_get_items_endpoint`].
|
||||||
|
#[cfg(test)]
|
||||||
/// Build the Jellyfin endpoint for a favourites listing.
|
|
||||||
///
|
|
||||||
/// Pulled out of `get_favorites` so the query can be asserted without an HTTP
|
|
||||||
/// server. `scope` is expanded here — `SearchScope::All` yields `None`, and the
|
|
||||||
/// `IncludeItemTypes` filter is then **omitted entirely** rather than sent as a
|
|
||||||
/// union, which would silently drop every type nobody enumerated (see
|
|
||||||
/// `SearchScope::item_types`).
|
|
||||||
///
|
|
||||||
/// TRACES: UR-067 | DR-115, JA-033 | UT-100
|
|
||||||
fn build_favorites_endpoint(
|
fn build_favorites_endpoint(
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
scope: SearchScope,
|
scope: SearchScope,
|
||||||
options: Option<&GetItemsOptions>,
|
options: Option<&GetItemsOptions>,
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut endpoint = format!("/Users/{}/Items?Filters=IsFavorite&Recursive=true", user_id);
|
endpoints::favorites(&ServerCapabilities::assumed(), user_id, scope, options)
|
||||||
|
|
||||||
if let Some(types) = scope.item_types() {
|
|
||||||
endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Jellyfin has no "date favourited", so name order is the only stable sort
|
|
||||||
// available; callers may still override it.
|
|
||||||
let sort_by = options
|
|
||||||
.and_then(|o| o.sort_by.as_deref())
|
|
||||||
.unwrap_or("SortName");
|
|
||||||
let sort_order = options
|
|
||||||
.and_then(|o| o.sort_order.as_deref())
|
|
||||||
.unwrap_or("Ascending");
|
|
||||||
endpoint.push_str(&format!("&SortBy={}&SortOrder={}", sort_by, sort_order));
|
|
||||||
|
|
||||||
if let Some(limit) = options.and_then(|o| o.limit) {
|
|
||||||
endpoint.push_str(&format!("&Limit={}", limit));
|
|
||||||
}
|
|
||||||
if let Some(start_index) = options.and_then(|o| o.start_index) {
|
|
||||||
endpoint.push_str(&format!("&StartIndex={}", start_index));
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint
|
|
||||||
.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
|
|
||||||
endpoint
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImageTags from Jellyfin API - can be a HashMap with various image type keys
|
// ImageTags from Jellyfin API - can be a HashMap with various image type keys
|
||||||
@@ -1810,7 +1694,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
image_tags: Option<ImageTags>,
|
image_tags: Option<ImageTags>,
|
||||||
}
|
}
|
||||||
|
|
||||||
let endpoint = format!("/Users/{}/Views", self.user_id);
|
let endpoint = endpoints::user_views(&self.capabilities, &self.user_id);
|
||||||
let response: LibrariesResponse = self.get_json(&endpoint).await?;
|
let response: LibrariesResponse = self.get_json(&endpoint).await?;
|
||||||
|
|
||||||
Ok(response
|
Ok(response
|
||||||
@@ -1832,7 +1716,12 @@ impl MediaRepository for OnlineRepository {
|
|||||||
parent_id: &str,
|
parent_id: &str,
|
||||||
options: Option<GetItemsOptions>,
|
options: Option<GetItemsOptions>,
|
||||||
) -> Result<SearchResult, RepoError> {
|
) -> Result<SearchResult, RepoError> {
|
||||||
let endpoint = build_get_items_endpoint(&self.user_id, parent_id, options.as_ref());
|
let endpoint = endpoints::get_items(
|
||||||
|
&self.capabilities,
|
||||||
|
&self.user_id,
|
||||||
|
parent_id,
|
||||||
|
options.as_ref(),
|
||||||
|
);
|
||||||
|
|
||||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||||
|
|
||||||
@@ -1858,7 +1747,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
///
|
///
|
||||||
/// TRACES: UR-021, UR-035 | IR-016, IR-022, JA-005, JA-009
|
/// TRACES: UR-021, UR-035 | IR-016, IR-022, JA-005, JA-009
|
||||||
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
|
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
|
||||||
let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate,UserData", self.user_id, urlencoding::encode(item_id));
|
let endpoint = endpoints::item_detail(&self.capabilities, &self.user_id, item_id);
|
||||||
|
|
||||||
let item: JellyfinItem = self.get_json(&endpoint).await?;
|
let item: JellyfinItem = self.get_json(&endpoint).await?;
|
||||||
let media_item = item.into_media_item(self.user_id.clone());
|
let media_item = item.into_media_item(self.user_id.clone());
|
||||||
@@ -1879,7 +1768,8 @@ impl MediaRepository for OnlineRepository {
|
|||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
) -> Result<Vec<MediaItem>, RepoError> {
|
) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
let limit_val = limit.unwrap_or(16);
|
let limit_val = limit.unwrap_or(16);
|
||||||
let endpoint = build_latest_items_endpoint(
|
let endpoint = endpoints::latest_items(
|
||||||
|
&self.capabilities,
|
||||||
&self.user_id,
|
&self.user_id,
|
||||||
parent_id,
|
parent_id,
|
||||||
Some(latest_items_fetch_limit(limit_val)),
|
Some(latest_items_fetch_limit(limit_val)),
|
||||||
@@ -1909,16 +1799,14 @@ impl MediaRepository for OnlineRepository {
|
|||||||
parent_id: Option<&str>,
|
parent_id: Option<&str>,
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
) -> Result<Vec<MediaItem>, RepoError> {
|
) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
let limit_str = limit.unwrap_or(16);
|
let endpoint = endpoints::resume_items(
|
||||||
let mut endpoint = format!(
|
&self.capabilities,
|
||||||
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
&self.user_id,
|
||||||
self.user_id, limit_str
|
limit.unwrap_or(16),
|
||||||
|
None,
|
||||||
|
parent_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(pid) = parent_id {
|
|
||||||
endpoint.push_str(&format!("&ParentId={}", pid));
|
|
||||||
}
|
|
||||||
|
|
||||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||||
Ok(response
|
Ok(response
|
||||||
.items
|
.items
|
||||||
@@ -1936,7 +1824,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
series_id: Option<&str>,
|
series_id: Option<&str>,
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
) -> Result<Vec<MediaItem>, RepoError> {
|
) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
let endpoint = build_next_up_endpoint(&self.user_id, series_id, limit);
|
let endpoint = endpoints::next_up(&self.capabilities, &self.user_id, series_id, limit);
|
||||||
|
|
||||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||||
Ok(response
|
Ok(response
|
||||||
@@ -1953,9 +1841,13 @@ impl MediaRepository for OnlineRepository {
|
|||||||
let limit_val = limit.unwrap_or(12);
|
let limit_val = limit.unwrap_or(12);
|
||||||
// Fetch more items to account for grouping reducing the count
|
// Fetch more items to account for grouping reducing the count
|
||||||
let fetch_limit = limit_val * 3;
|
let fetch_limit = limit_val * 3;
|
||||||
let endpoint = format!(
|
let endpoint = endpoints::played_items_by_date(
|
||||||
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Descending&IncludeItemTypes=Audio&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
&self.capabilities,
|
||||||
self.user_id, fetch_limit
|
&self.user_id,
|
||||||
|
"Audio",
|
||||||
|
fetch_limit,
|
||||||
|
"Descending",
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||||
@@ -2087,15 +1979,15 @@ impl MediaRepository for OnlineRepository {
|
|||||||
// Ask Jellyfin for played albums sorted by least-recently played first.
|
// Ask Jellyfin for played albums sorted by least-recently played first.
|
||||||
// Filters=IsPlayed keeps only albums the user has actually listened to,
|
// Filters=IsPlayed keeps only albums the user has actually listened to,
|
||||||
// and SortBy=DatePlayed ascending surfaces the ones they've neglected.
|
// and SortBy=DatePlayed ascending surfaces the ones they've neglected.
|
||||||
let mut endpoint = format!(
|
let endpoint = endpoints::played_items_by_date(
|
||||||
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Ascending&IncludeItemTypes=MusicAlbum&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
&self.capabilities,
|
||||||
self.user_id, limit_val
|
&self.user_id,
|
||||||
|
"MusicAlbum",
|
||||||
|
limit_val,
|
||||||
|
"Ascending",
|
||||||
|
parent_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(pid) = parent_id {
|
|
||||||
endpoint.push_str(&format!("&ParentId={}", pid));
|
|
||||||
}
|
|
||||||
|
|
||||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||||
Ok(response
|
Ok(response
|
||||||
.items
|
.items
|
||||||
@@ -2110,10 +2002,12 @@ impl MediaRepository for OnlineRepository {
|
|||||||
///
|
///
|
||||||
/// TRACES: UR-019, UR-034 | IR-024, JA-013, JA-015
|
/// TRACES: UR-019, UR-034 | IR-024, JA-013, JA-015
|
||||||
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
let limit_str = limit.unwrap_or(16);
|
let endpoint = endpoints::resume_items(
|
||||||
let endpoint = format!(
|
&self.capabilities,
|
||||||
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&IncludeItemTypes=Movie&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
&self.user_id,
|
||||||
self.user_id, limit_str
|
limit.unwrap_or(16),
|
||||||
|
Some("Movie"),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||||
@@ -2127,14 +2021,8 @@ impl MediaRepository for OnlineRepository {
|
|||||||
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
||||||
// Ask Jellyfin to scope counts to albums and include them, so the
|
// Ask Jellyfin to scope counts to albums and include them, so the
|
||||||
// frontend can rank genres by popularity without probing each one.
|
// frontend can rank genres by popularity without probing each one.
|
||||||
let mut endpoint = format!(
|
let endpoint =
|
||||||
"/Genres?UserId={}&IncludeItemTypes=MusicAlbum&Recursive=true&Fields=ItemCounts",
|
endpoints::genres(&self.capabilities, &self.user_id, "MusicAlbum", parent_id);
|
||||||
self.user_id
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(pid) = parent_id {
|
|
||||||
endpoint.push_str(&format!("&ParentId={}", pid));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "PascalCase")]
|
#[serde(rename_all = "PascalCase")]
|
||||||
@@ -2201,28 +2089,12 @@ impl MediaRepository for OnlineRepository {
|
|||||||
// SearchTerm is arbitrary user input and must be percent-encoded so that
|
// SearchTerm is arbitrary user input and must be percent-encoded so that
|
||||||
// spaces, ampersands, etc. don't corrupt the query string (a multi-word
|
// spaces, ampersands, etc. don't corrupt the query string (a multi-word
|
||||||
// search like "Star Wars" would otherwise produce a malformed URL).
|
// search like "Star Wars" would otherwise produce a malformed URL).
|
||||||
let mut endpoint = format!(
|
let endpoint = endpoints::search(
|
||||||
"/Users/{}/Items?SearchTerm={}&Limit={}&Recursive=true",
|
&self.capabilities,
|
||||||
self.user_id,
|
&self.user_id,
|
||||||
urlencoding::encode(query),
|
query,
|
||||||
limit
|
limit,
|
||||||
);
|
options.and_then(|o| o.include_item_types).as_deref(),
|
||||||
|
|
||||||
if let Some(opts) = options {
|
|
||||||
if let Some(types) = opts.include_item_types {
|
|
||||||
let encoded_types = types
|
|
||||||
.iter()
|
|
||||||
.map(|t| urlencoding::encode(t).into_owned())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(",");
|
|
||||||
endpoint.push_str(&format!("&IncludeItemTypes={}", encoded_types));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request image fields for list views (plus Genres so cached items
|
|
||||||
// carry genres for offline genre lists/counts).
|
|
||||||
endpoint.push_str(
|
|
||||||
"&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||||
@@ -2311,7 +2183,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
// serves the original file untouched, and pinning index 0 (the video
|
// serves the original file untouched, and pinning index 0 (the video
|
||||||
// stream) only misleads servers that do honour it.
|
// stream) only misleads servers that do honour it.
|
||||||
format!(
|
format!(
|
||||||
"{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId=jellytau&api_key={}&userId={}",
|
"{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId=jellytau&ApiKey={}&userId={}",
|
||||||
self.server_url,
|
self.server_url,
|
||||||
item_id,
|
item_id,
|
||||||
source.id,
|
source.id,
|
||||||
@@ -2335,7 +2207,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError> {
|
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError> {
|
||||||
// Construct direct audio stream URL
|
// Construct direct audio stream URL
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"{}/Audio/{}/stream?UserId={}&api_key={}&Static=true",
|
"{}/Audio/{}/stream?UserId={}&ApiKey={}&Static=true",
|
||||||
self.server_url, item_id, self.user_id, self.access_token
|
self.server_url, item_id, self.user_id, self.access_token
|
||||||
);
|
);
|
||||||
Ok(url)
|
Ok(url)
|
||||||
@@ -2360,10 +2232,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
// Live TV channels (broadcast tuners / IPTV M3U). Returned as items with
|
// Live TV channels (broadcast tuners / IPTV M3U). Returned as items with
|
||||||
// type "TvChannel" — playable via open_live_stream.
|
// type "TvChannel" — playable via open_live_stream.
|
||||||
let endpoint = format!(
|
let endpoint = endpoints::live_tv_channels(&self.capabilities, &self.user_id);
|
||||||
"/LiveTv/Channels?UserId={}&Fields=PrimaryImageAspectRatio,Overview&EnableImageTypes=Primary",
|
|
||||||
self.user_id
|
|
||||||
);
|
|
||||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||||
Ok(response
|
Ok(response
|
||||||
.items
|
.items
|
||||||
@@ -2375,7 +2244,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
|
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
|
||||||
// Root list of plugin "Channels". Drill-down into a channel folder reuses
|
// Root list of plugin "Channels". Drill-down into a channel folder reuses
|
||||||
// get_items(channel_id, ...).
|
// get_items(channel_id, ...).
|
||||||
let endpoint = format!("/Channels?UserId={}", self.user_id);
|
let endpoint = endpoints::channels(&self.capabilities, &self.user_id);
|
||||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||||
let total = response.total_record_count;
|
let total = response.total_record_count;
|
||||||
let items = response
|
let items = response
|
||||||
@@ -2426,7 +2295,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
live_stream_id: Option<String>,
|
live_stream_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
let endpoint = format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id));
|
let endpoint = endpoints::playback_info(&self.capabilities, item_id);
|
||||||
let request = OpenLiveStreamRequest {
|
let request = OpenLiveStreamRequest {
|
||||||
user_id: self.user_id.clone(),
|
user_id: self.user_id.clone(),
|
||||||
auto_open_live_stream: true,
|
auto_open_live_stream: true,
|
||||||
@@ -2461,7 +2330,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
super::device_profile::without_server_chosen_subtitle(&url)
|
super::device_profile::without_server_chosen_subtitle(&url)
|
||||||
),
|
),
|
||||||
None => format!(
|
None => format!(
|
||||||
"{}/Videos/{}/master.m3u8?api_key={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts&SubtitleStreamIndex={}",
|
"{}/Videos/{}/master.m3u8?ApiKey={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts&SubtitleStreamIndex={}",
|
||||||
self.server_url,
|
self.server_url,
|
||||||
item_id,
|
item_id,
|
||||||
self.access_token,
|
self.access_token,
|
||||||
@@ -2503,7 +2372,8 @@ impl MediaRepository for OnlineRepository {
|
|||||||
is_paused: false,
|
is_paused: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
self.post_json("/Sessions/Playing", &request).await
|
self.post_json(endpoints::sessions_playing(&self.capabilities), &request)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn report_playback_progress(
|
async fn report_playback_progress(
|
||||||
@@ -2525,7 +2395,11 @@ impl MediaRepository for OnlineRepository {
|
|||||||
is_paused: false,
|
is_paused: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
self.post_json("/Sessions/Playing/Progress", &request).await
|
self.post_json(
|
||||||
|
endpoints::sessions_playing_progress(&self.capabilities),
|
||||||
|
&request,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn report_playback_stopped(
|
async fn report_playback_stopped(
|
||||||
@@ -2545,7 +2419,11 @@ impl MediaRepository for OnlineRepository {
|
|||||||
position_ticks,
|
position_ticks,
|
||||||
};
|
};
|
||||||
|
|
||||||
self.post_json("/Sessions/Playing/Stopped", &request).await
|
self.post_json(
|
||||||
|
endpoints::sessions_playing_stopped(&self.capabilities),
|
||||||
|
&request,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_image_url(
|
fn get_image_url(
|
||||||
@@ -2561,9 +2439,10 @@ impl MediaRepository for OnlineRepository {
|
|||||||
image_type.as_str()
|
image_type.as_str()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Authentication is handled by X-Emby-Authorization header in download_bytes()
|
// Authentication is handled by the `Authorization` header in
|
||||||
// Do NOT include api_key here — some Jellyfin servers reject requests when
|
// download_bytes(). Do NOT add a query-parameter token here — some
|
||||||
// api_key is present but the token doesn't match the expected format.
|
// Jellyfin servers reject requests carrying one whose format they do not
|
||||||
|
// expect, and this request can already authenticate by header.
|
||||||
let mut params: Vec<String> = Vec::new();
|
let mut params: Vec<String> = Vec::new();
|
||||||
|
|
||||||
if let Some(opts) = options {
|
if let Some(opts) = options {
|
||||||
@@ -2623,7 +2502,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
// instead — it is always present and supports HTTP Range, which the
|
// instead — it is always present and supports HTTP Range, which the
|
||||||
// download worker relies on for resume.
|
// download worker relies on for resume.
|
||||||
let mut url = format!("{}/Videos/{}/stream.mp4", self.server_url, item_id);
|
let mut url = format!("{}/Videos/{}/stream.mp4", self.server_url, item_id);
|
||||||
let mut params = vec![format!("api_key={}", self.access_token)];
|
let mut params = vec![format!("ApiKey={}", self.access_token)];
|
||||||
|
|
||||||
// Map the frontend quality preset to concrete transcode params. For
|
// Map the frontend quality preset to concrete transcode params. For
|
||||||
// "original" we request a direct static copy (no transcode) which is
|
// "original" we request a direct static copy (no transcode) which is
|
||||||
@@ -2713,11 +2592,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
||||||
let endpoint = format!(
|
let endpoint = endpoints::favorite_item(&self.capabilities, &self.user_id, item_id);
|
||||||
"/Users/{}/FavoriteItems/{}",
|
|
||||||
self.user_id,
|
|
||||||
urlencoding::encode(item_id)
|
|
||||||
);
|
|
||||||
self.post_json(&endpoint, &serde_json::json!({})).await
|
self.post_json(&endpoint, &serde_json::json!({})).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2727,7 +2602,8 @@ impl MediaRepository for OnlineRepository {
|
|||||||
scope: SearchScope,
|
scope: SearchScope,
|
||||||
options: Option<GetItemsOptions>,
|
options: Option<GetItemsOptions>,
|
||||||
) -> Result<SearchResult, RepoError> {
|
) -> Result<SearchResult, RepoError> {
|
||||||
let endpoint = build_favorites_endpoint(&self.user_id, scope, options.as_ref());
|
let endpoint =
|
||||||
|
endpoints::favorites(&self.capabilities, &self.user_id, scope, options.as_ref());
|
||||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||||
|
|
||||||
Ok(SearchResult {
|
Ok(SearchResult {
|
||||||
@@ -2747,11 +2623,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
///
|
///
|
||||||
/// TRACES: UR-017 | JA-018, DR-021
|
/// TRACES: UR-017 | JA-018, DR-021
|
||||||
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
||||||
let endpoint = format!(
|
let endpoint = endpoints::favorite_item(&self.capabilities, &self.user_id, item_id);
|
||||||
"/Users/{}/FavoriteItems/{}",
|
|
||||||
self.user_id,
|
|
||||||
urlencoding::encode(item_id)
|
|
||||||
);
|
|
||||||
let url = format!("{}{}", self.server_url, endpoint);
|
let url = format!("{}{}", self.server_url, endpoint);
|
||||||
|
|
||||||
let result = async {
|
let result = async {
|
||||||
@@ -2759,7 +2631,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
.http_client
|
.http_client
|
||||||
.client
|
.client
|
||||||
.delete(&url)
|
.delete(&url)
|
||||||
.header("X-Emby-Authorization", self.auth_header())
|
.header("Authorization", self.auth_header())
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| RepoError::Network {
|
.map_err(|e| RepoError::Network {
|
||||||
message: format!("Failed to build request: {}", e),
|
message: format!("Failed to build request: {}", e),
|
||||||
@@ -2793,11 +2665,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
///
|
///
|
||||||
/// TRACES: UR-064 | DR-106, JA-033
|
/// TRACES: UR-064 | DR-106, JA-033
|
||||||
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
|
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
|
||||||
let endpoint = format!(
|
let endpoint = endpoints::played_item(&self.capabilities, &self.user_id, item_id);
|
||||||
"/Users/{}/PlayedItems/{}",
|
|
||||||
self.user_id,
|
|
||||||
urlencoding::encode(item_id)
|
|
||||||
);
|
|
||||||
let url = format!("{}{}", self.server_url, endpoint);
|
let url = format!("{}{}", self.server_url, endpoint);
|
||||||
|
|
||||||
let result = async {
|
let result = async {
|
||||||
@@ -2805,7 +2673,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
.http_client
|
.http_client
|
||||||
.client
|
.client
|
||||||
.delete(&url)
|
.delete(&url)
|
||||||
.header("X-Emby-Authorization", self.auth_header())
|
.header("Authorization", self.auth_header())
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| RepoError::Network {
|
.map_err(|e| RepoError::Network {
|
||||||
message: format!("Failed to build request: {}", e),
|
message: format!("Failed to build request: {}", e),
|
||||||
@@ -2838,11 +2706,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
///
|
///
|
||||||
/// TRACES: UR-025 | DR-131 | JA-035
|
/// TRACES: UR-025 | DR-131 | JA-035
|
||||||
async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
|
async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
|
||||||
let endpoint = format!(
|
let endpoint = endpoints::played_item(&self.capabilities, &self.user_id, item_id);
|
||||||
"/Users/{}/PlayedItems/{}",
|
|
||||||
self.user_id,
|
|
||||||
urlencoding::encode(item_id)
|
|
||||||
);
|
|
||||||
let url = format!("{}{}", self.server_url, endpoint);
|
let url = format!("{}{}", self.server_url, endpoint);
|
||||||
|
|
||||||
let result = async {
|
let result = async {
|
||||||
@@ -2850,7 +2714,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
.http_client
|
.http_client
|
||||||
.client
|
.client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.header("X-Emby-Authorization", self.auth_header())
|
.header("Authorization", self.auth_header())
|
||||||
.header("Content-Length", "0")
|
.header("Content-Length", "0")
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| RepoError::Network {
|
.map_err(|e| RepoError::Network {
|
||||||
@@ -2887,11 +2751,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
///
|
///
|
||||||
/// TRACES: UR-035, UR-036 | IR-022, JA-030
|
/// TRACES: UR-035, UR-036 | IR-022, JA-030
|
||||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||||
let endpoint = format!(
|
let endpoint = endpoints::person(&self.capabilities, &self.user_id, person_id);
|
||||||
"/Users/{}/Items/{}",
|
|
||||||
self.user_id,
|
|
||||||
urlencoding::encode(person_id)
|
|
||||||
);
|
|
||||||
let item: JellyfinItem = self.get_json(&endpoint).await?;
|
let item: JellyfinItem = self.get_json(&endpoint).await?;
|
||||||
Ok(item.into_media_item(self.user_id.clone()))
|
Ok(item.into_media_item(self.user_id.clone()))
|
||||||
}
|
}
|
||||||
@@ -2906,21 +2766,16 @@ impl MediaRepository for OnlineRepository {
|
|||||||
) -> Result<SearchResult, RepoError> {
|
) -> Result<SearchResult, RepoError> {
|
||||||
let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(100);
|
let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(100);
|
||||||
|
|
||||||
let mut endpoint = format!(
|
let endpoint = endpoints::items_by_person(
|
||||||
"/Users/{}/Items?PersonIds={}&Limit={}&Recursive=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
&self.capabilities,
|
||||||
self.user_id, person_id, limit
|
&self.user_id,
|
||||||
|
person_id,
|
||||||
|
limit,
|
||||||
|
options
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|o| o.include_item_types.as_deref()),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add item type filtering if specified in options
|
|
||||||
if let Some(ref opts) = options {
|
|
||||||
if let Some(ref include_types) = opts.include_item_types {
|
|
||||||
if !include_types.is_empty() {
|
|
||||||
let types_param = include_types.join(",");
|
|
||||||
endpoint.push_str(&format!("&IncludeItemTypes={}", types_param));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||||
Ok(SearchResult {
|
Ok(SearchResult {
|
||||||
items: response
|
items: response
|
||||||
@@ -2940,10 +2795,8 @@ impl MediaRepository for OnlineRepository {
|
|||||||
let limit_str = limit.unwrap_or(20);
|
let limit_str = limit.unwrap_or(20);
|
||||||
|
|
||||||
// Try the /Similar endpoint which works for most items
|
// Try the /Similar endpoint which works for most items
|
||||||
let endpoint = format!(
|
let endpoint =
|
||||||
"/Items/{}/Similar?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
endpoints::similar_items(&self.capabilities, item_id, &self.user_id, limit_str);
|
||||||
item_id, self.user_id, limit_str
|
|
||||||
);
|
|
||||||
|
|
||||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||||
Ok(SearchResult {
|
Ok(SearchResult {
|
||||||
@@ -2974,20 +2827,22 @@ impl MediaRepository for OnlineRepository {
|
|||||||
"MediaType": "Audio",
|
"MediaType": "Audio",
|
||||||
"UserId": self.user_id,
|
"UserId": self.user_id,
|
||||||
});
|
});
|
||||||
let response: CreatePlaylistResponse = self.post_json_response("/Playlists", &body).await?;
|
let response: CreatePlaylistResponse = self
|
||||||
|
.post_json_response(endpoints::playlists(&self.capabilities), &body)
|
||||||
|
.await?;
|
||||||
Ok(PlaylistCreatedResult { id: response.id })
|
Ok(PlaylistCreatedResult { id: response.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
|
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
|
||||||
info!("[OnlineRepo] Deleting playlist {}", playlist_id);
|
info!("[OnlineRepo] Deleting playlist {}", playlist_id);
|
||||||
let endpoint = format!("/Items/{}", urlencoding::encode(playlist_id));
|
let endpoint = endpoints::playlist_as_item(&self.capabilities, playlist_id);
|
||||||
let url = format!("{}{}", self.server_url, endpoint);
|
let url = format!("{}{}", self.server_url, endpoint);
|
||||||
|
|
||||||
let request = self
|
let request = self
|
||||||
.http_client
|
.http_client
|
||||||
.client
|
.client
|
||||||
.delete(&url)
|
.delete(&url)
|
||||||
.header("X-Emby-Authorization", self.auth_header())
|
.header("Authorization", self.auth_header())
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| RepoError::Network {
|
.map_err(|e| RepoError::Network {
|
||||||
message: format!("Failed to build request: {}", e),
|
message: format!("Failed to build request: {}", e),
|
||||||
@@ -3015,16 +2870,13 @@ impl MediaRepository for OnlineRepository {
|
|||||||
"[OnlineRepo] Renaming playlist {} to '{}'",
|
"[OnlineRepo] Renaming playlist {} to '{}'",
|
||||||
playlist_id, name
|
playlist_id, name
|
||||||
);
|
);
|
||||||
let endpoint = format!("/Items/{}", urlencoding::encode(playlist_id));
|
let endpoint = endpoints::playlist_as_item(&self.capabilities, playlist_id);
|
||||||
self.post_json(&endpoint, &serde_json::json!({ "Name": name }))
|
self.post_json(&endpoint, &serde_json::json!({ "Name": name }))
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
|
async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
|
||||||
let endpoint = format!(
|
let endpoint = endpoints::playlist_items(&self.capabilities, playlist_id, &self.user_id);
|
||||||
"/Playlists/{}/Items?UserId={}&Fields=PrimaryImageTag,Artists,AlbumId,Album,AlbumArtist,RunTimeTicks,ArtistItems&StartIndex=0&Limit=10000",
|
|
||||||
playlist_id, self.user_id
|
|
||||||
);
|
|
||||||
|
|
||||||
let response: PlaylistItemsResponse = self.get_json(&endpoint).await?;
|
let response: PlaylistItemsResponse = self.get_json(&endpoint).await?;
|
||||||
debug!(
|
debug!(
|
||||||
@@ -3059,11 +2911,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
.map(|id| urlencoding::encode(id).into_owned())
|
.map(|id| urlencoding::encode(id).into_owned())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(",");
|
.join(",");
|
||||||
let endpoint = format!(
|
let endpoint = endpoints::playlist_items_add(&self.capabilities, playlist_id, &ids_param);
|
||||||
"/Playlists/{}/Items?Ids={}",
|
|
||||||
urlencoding::encode(playlist_id),
|
|
||||||
ids_param
|
|
||||||
);
|
|
||||||
self.post_json(&endpoint, &serde_json::json!({})).await
|
self.post_json(&endpoint, &serde_json::json!({})).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3082,18 +2930,15 @@ impl MediaRepository for OnlineRepository {
|
|||||||
.map(|id| urlencoding::encode(id).into_owned())
|
.map(|id| urlencoding::encode(id).into_owned())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(",");
|
.join(",");
|
||||||
let endpoint = format!(
|
let endpoint =
|
||||||
"/Playlists/{}/Items?EntryIds={}",
|
endpoints::playlist_items_remove(&self.capabilities, playlist_id, &ids_param);
|
||||||
urlencoding::encode(playlist_id),
|
|
||||||
ids_param
|
|
||||||
);
|
|
||||||
let url = format!("{}{}", self.server_url, endpoint);
|
let url = format!("{}{}", self.server_url, endpoint);
|
||||||
|
|
||||||
let request = self
|
let request = self
|
||||||
.http_client
|
.http_client
|
||||||
.client
|
.client
|
||||||
.delete(&url)
|
.delete(&url)
|
||||||
.header("X-Emby-Authorization", self.auth_header())
|
.header("Authorization", self.auth_header())
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| RepoError::Network {
|
.map_err(|e| RepoError::Network {
|
||||||
message: format!("Failed to build request: {}", e),
|
message: format!("Failed to build request: {}", e),
|
||||||
@@ -3126,10 +2971,8 @@ impl MediaRepository for OnlineRepository {
|
|||||||
"[OnlineRepo] Moving item {} in playlist {} to index {}",
|
"[OnlineRepo] Moving item {} in playlist {} to index {}",
|
||||||
item_id, playlist_id, new_index
|
item_id, playlist_id, new_index
|
||||||
);
|
);
|
||||||
let endpoint = format!(
|
let endpoint =
|
||||||
"/Playlists/{}/Items/{}/Move/{}",
|
endpoints::playlist_item_move(&self.capabilities, playlist_id, item_id, new_index);
|
||||||
playlist_id, item_id, new_index
|
|
||||||
);
|
|
||||||
self.post_json(&endpoint, &serde_json::json!({})).await
|
self.post_json(&endpoint, &serde_json::json!({})).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3310,7 +3153,7 @@ mod tests {
|
|||||||
let url = result.unwrap();
|
let url = result.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
url,
|
url,
|
||||||
"https://test.server.com/Audio/test-track-123/stream?UserId=test-user-id&api_key=test-access-token&Static=true"
|
"https://test.server.com/Audio/test-track-123/stream?UserId=test-user-id&ApiKey=test-access-token&Static=true"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3765,10 +3608,14 @@ mod tests {
|
|||||||
// ===== Video download URL (real impl) =====
|
// ===== Video download URL (real impl) =====
|
||||||
//
|
//
|
||||||
// These exercise the PRODUCTION `OnlineRepository::get_video_download_url`,
|
// These exercise the PRODUCTION `OnlineRepository::get_video_download_url`,
|
||||||
// not a mock. A prior mock in online_integration_test.rs used the correct
|
// not a mock. A prior mock used the correct `stream.mp4` endpoint while the
|
||||||
// `stream.mp4` endpoint while the real impl shipped `/Videos/{id}/download`,
|
// real impl shipped `/Videos/{id}/download`, which returns 404 on real
|
||||||
// which returns 404 on real servers and silently broke every movie/TV
|
// servers and silently broke every movie/TV download. That mock lived in
|
||||||
// download. Assert the real builder targets the resumable stream endpoint.
|
// `online_integration_test.rs`, which was never declared as a module and so
|
||||||
|
// never compiled — it was deleted for that reason, and this is the lesson it
|
||||||
|
// left: a mock that reimplements the builder asserts on itself, and passes
|
||||||
|
// just as happily when production is wrong. Assert the real builder targets
|
||||||
|
// the resumable stream endpoint.
|
||||||
//
|
//
|
||||||
// @req-test: DR-013 - Repository pattern for online/offline data access
|
// @req-test: DR-013 - Repository pattern for online/offline data access
|
||||||
|
|
||||||
@@ -3787,7 +3634,7 @@ mod tests {
|
|||||||
url.contains("/Videos/item123/stream.mp4"),
|
url.contains("/Videos/item123/stream.mp4"),
|
||||||
"download URL must target /Videos/{{id}}/stream.mp4: {url}"
|
"download URL must target /Videos/{{id}}/stream.mp4: {url}"
|
||||||
);
|
);
|
||||||
assert!(url.contains("api_key=test-access-token"), "url: {url}");
|
assert!(url.contains("ApiKey=test-access-token"), "url: {url}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,429 +0,0 @@
|
|||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use crate::api::jellyfin::{
|
|
||||||
GetItemsOptions, ImageType, ImageOptions, SortOrder,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Mock for testing URL construction without a real server
|
|
||||||
struct MockOnlineRepository {
|
|
||||||
server_url: String,
|
|
||||||
access_token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MockOnlineRepository {
|
|
||||||
fn new(server_url: &str, access_token: &str) -> Self {
|
|
||||||
Self {
|
|
||||||
server_url: server_url.to_string(),
|
|
||||||
access_token: access_token.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test helper: construct image URL similar to backend
|
|
||||||
fn get_image_url(
|
|
||||||
&self,
|
|
||||||
item_id: &str,
|
|
||||||
image_type: &str,
|
|
||||||
options: Option<&ImageOptions>,
|
|
||||||
) -> String {
|
|
||||||
let mut url = format!(
|
|
||||||
"{}/Items/{}/Images/{}",
|
|
||||||
self.server_url, item_id, image_type
|
|
||||||
);
|
|
||||||
|
|
||||||
// No api_key — image downloads use X-Emby-Authorization header
|
|
||||||
let mut params: Vec<(&str, String)> = Vec::new();
|
|
||||||
|
|
||||||
if let Some(opts) = options {
|
|
||||||
if let Some(max_width) = opts.max_width {
|
|
||||||
params.push(("maxWidth", max_width.to_string()));
|
|
||||||
}
|
|
||||||
if let Some(max_height) = opts.max_height {
|
|
||||||
params.push(("maxHeight", max_height.to_string()));
|
|
||||||
}
|
|
||||||
if let Some(quality) = opts.quality {
|
|
||||||
params.push(("quality", quality.to_string()));
|
|
||||||
}
|
|
||||||
if let Some(tag) = &opts.tag {
|
|
||||||
params.push(("tag", tag.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let query_string = params
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| format!("{}={}", k, v))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("&");
|
|
||||||
|
|
||||||
if !query_string.is_empty() {
|
|
||||||
url.push('?');
|
|
||||||
url.push_str(&query_string);
|
|
||||||
}
|
|
||||||
|
|
||||||
url
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test helper: construct subtitle URL
|
|
||||||
fn get_subtitle_url(
|
|
||||||
&self,
|
|
||||||
item_id: &str,
|
|
||||||
media_source_id: &str,
|
|
||||||
stream_index: usize,
|
|
||||||
format: &str,
|
|
||||||
) -> String {
|
|
||||||
format!(
|
|
||||||
"{}/Videos/{}/Subtitles/{}/{}/subtitles.{}?api_key={}",
|
|
||||||
self.server_url,
|
|
||||||
item_id,
|
|
||||||
media_source_id,
|
|
||||||
stream_index,
|
|
||||||
format,
|
|
||||||
self.access_token
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test helper: construct video download URL
|
|
||||||
fn get_video_download_url(
|
|
||||||
&self,
|
|
||||||
item_id: &str,
|
|
||||||
quality: &str,
|
|
||||||
) -> String {
|
|
||||||
let (max_width, bitrate) = match quality {
|
|
||||||
"1080p" => ("1920", "15000k"),
|
|
||||||
"720p" => ("1280", "8000k"),
|
|
||||||
"480p" => ("854", "3000k"),
|
|
||||||
_ => ("0", ""), // original
|
|
||||||
};
|
|
||||||
|
|
||||||
if quality == "original" {
|
|
||||||
format!("{}/Videos/{}/stream.mp4?api_key={}", self.server_url, item_id, self.access_token)
|
|
||||||
} else {
|
|
||||||
format!(
|
|
||||||
"{}/Videos/{}/stream.mp4?maxWidth={}&videoBitrate={}&api_key={}",
|
|
||||||
self.server_url, item_id, max_width, bitrate, self.access_token
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Image URL Tests =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_image_url_basic() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
|
|
||||||
let url = repo.get_image_url("item123", "Primary", None);
|
|
||||||
|
|
||||||
assert!(url.contains("https://jellyfin.example.com"));
|
|
||||||
assert!(url.contains("/Items/item123/Images/Primary"));
|
|
||||||
assert!(url.contains("api_key=token123"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_image_url_with_max_width() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
let options = ImageOptions {
|
|
||||||
max_width: Some(300),
|
|
||||||
max_height: None,
|
|
||||||
quality: None,
|
|
||||||
tag: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = repo.get_image_url("item123", "Primary", Some(&options));
|
|
||||||
|
|
||||||
assert!(url.contains("maxWidth=300"));
|
|
||||||
assert!(url.contains("api_key=token123"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_image_url_with_all_options() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
let options = ImageOptions {
|
|
||||||
max_width: Some(1920),
|
|
||||||
max_height: Some(1080),
|
|
||||||
quality: Some(90),
|
|
||||||
tag: Some("abc123".to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = repo.get_image_url("item456", "Backdrop", Some(&options));
|
|
||||||
|
|
||||||
assert!(url.contains("/Items/item456/Images/Backdrop"));
|
|
||||||
assert!(url.contains("maxWidth=1920"));
|
|
||||||
assert!(url.contains("maxHeight=1080"));
|
|
||||||
assert!(url.contains("quality=90"));
|
|
||||||
assert!(url.contains("tag=abc123"));
|
|
||||||
assert!(url.contains("api_key=token123"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_image_url_different_image_types() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
|
|
||||||
let image_types = vec!["Primary", "Backdrop", "Logo", "Thumb"];
|
|
||||||
|
|
||||||
for image_type in image_types {
|
|
||||||
let url = repo.get_image_url("item123", image_type, None);
|
|
||||||
assert!(url.contains(&format!("/Images/{}", image_type)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_image_url_credentials_included_in_backend() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "secret_token");
|
|
||||||
|
|
||||||
let url = repo.get_image_url("item123", "Primary", None);
|
|
||||||
|
|
||||||
// Credentials should be included in backend-generated URL
|
|
||||||
assert!(url.contains("api_key=secret_token"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_image_url_proper_encoding() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
let options = ImageOptions {
|
|
||||||
max_width: Some(300),
|
|
||||||
max_height: None,
|
|
||||||
quality: None,
|
|
||||||
tag: Some("tag-with-special-chars".to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = repo.get_image_url("item123", "Primary", Some(&options));
|
|
||||||
|
|
||||||
// URL should be properly formatted
|
|
||||||
assert!(url.contains("?"));
|
|
||||||
assert!(url.contains("&") || !url.contains("&&")); // No double ampersands
|
|
||||||
assert!(!url.ends_with("&")); // No trailing ampersand
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Subtitle URL Tests =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_subtitle_url_vtt_format() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
|
|
||||||
let url = repo.get_subtitle_url("item123", "source456", 0, "vtt");
|
|
||||||
|
|
||||||
assert!(url.contains("Videos/item123"));
|
|
||||||
assert!(url.contains("Subtitles/source456/0"));
|
|
||||||
assert!(url.contains("subtitles.vtt"));
|
|
||||||
assert!(url.contains("api_key=token123"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_subtitle_url_srt_format() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
|
|
||||||
let url = repo.get_subtitle_url("item123", "source456", 1, "srt");
|
|
||||||
|
|
||||||
assert!(url.contains("Subtitles/source456/1"));
|
|
||||||
assert!(url.contains("subtitles.srt"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_subtitle_url_multiple_streams() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
|
|
||||||
for stream_index in 0..5 {
|
|
||||||
let url = repo.get_subtitle_url("item123", "source456", stream_index, "vtt");
|
|
||||||
assert!(url.contains(&format!("/{}/subtitles", stream_index)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_subtitle_url_different_media_sources() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
|
|
||||||
let media_sources = vec!["src1", "src2", "src3"];
|
|
||||||
|
|
||||||
for media_source_id in media_sources {
|
|
||||||
let url = repo.get_subtitle_url("item123", media_source_id, 0, "vtt");
|
|
||||||
assert!(url.contains(&format!("Subtitles/{}/", media_source_id)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Video Download URL Tests =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_video_download_url_original_quality() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
|
|
||||||
let url = repo.get_video_download_url("item123", "original");
|
|
||||||
|
|
||||||
assert!(url.contains("Videos/item123/stream.mp4"));
|
|
||||||
assert!(url.contains("api_key=token123"));
|
|
||||||
assert!(!url.contains("maxWidth")); // Original should have no transcoding params
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_video_download_url_1080p() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
|
|
||||||
let url = repo.get_video_download_url("item123", "1080p");
|
|
||||||
|
|
||||||
assert!(url.contains("maxWidth=1920"));
|
|
||||||
assert!(url.contains("videoBitrate=15000k"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_video_download_url_720p() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
|
|
||||||
let url = repo.get_video_download_url("item123", "720p");
|
|
||||||
|
|
||||||
assert!(url.contains("maxWidth=1280"));
|
|
||||||
assert!(url.contains("videoBitrate=8000k"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_video_download_url_480p() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
|
|
||||||
let url = repo.get_video_download_url("item123", "480p");
|
|
||||||
|
|
||||||
assert!(url.contains("maxWidth=854"));
|
|
||||||
assert!(url.contains("videoBitrate=3000k"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_video_download_url_quality_presets() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
|
|
||||||
let qualities = vec!["original", "1080p", "720p", "480p"];
|
|
||||||
|
|
||||||
for quality in qualities {
|
|
||||||
let url = repo.get_video_download_url("item123", quality);
|
|
||||||
assert!(url.contains("Videos/item123/stream.mp4"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Security Tests =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_credentials_never_exposed_in_frontend() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "super_secret_token");
|
|
||||||
|
|
||||||
let image_url = repo.get_image_url("item123", "Primary", None);
|
|
||||||
let subtitle_url = repo.get_subtitle_url("item123", "src123", 0, "vtt");
|
|
||||||
let download_url = repo.get_video_download_url("item123", "720p");
|
|
||||||
|
|
||||||
// Image URLs no longer contain api_key — auth is via X-Emby-Authorization header
|
|
||||||
assert!(!image_url.contains("api_key="));
|
|
||||||
// Subtitle and download URLs still use api_key (used directly, not via download_bytes)
|
|
||||||
assert!(subtitle_url.contains("api_key=super_secret_token"));
|
|
||||||
assert!(download_url.contains("api_key=super_secret_token"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_url_parameter_injection_prevention() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
|
|
||||||
// Try to inject parameters through item_id
|
|
||||||
let malicious_id = "item123&extraParam=malicious";
|
|
||||||
let url = repo.get_image_url(malicious_id, "Primary", None);
|
|
||||||
|
|
||||||
// URL should contain the full item_id, backend should handle escaping
|
|
||||||
assert!(url.contains(malicious_id));
|
|
||||||
// Backend should be responsible for proper URL encoding
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== URL Format Tests =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_image_url_format_correctness() {
|
|
||||||
let repo = MockOnlineRepository::new("https://server.com", "token");
|
|
||||||
|
|
||||||
let url = repo.get_image_url("id123", "Primary", None);
|
|
||||||
|
|
||||||
// Should be valid format (no api_key — auth via header)
|
|
||||||
assert!(url.starts_with("https://server.com"));
|
|
||||||
assert!(url.contains("/Items/id123/Images/Primary"));
|
|
||||||
assert!(!url.contains("api_key="));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_query_string_properly_separated() {
|
|
||||||
let repo = MockOnlineRepository::new("https://server.com", "token");
|
|
||||||
let options = ImageOptions {
|
|
||||||
max_width: Some(300),
|
|
||||||
max_height: Some(200),
|
|
||||||
quality: None,
|
|
||||||
tag: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = repo.get_image_url("id123", "Primary", Some(&options));
|
|
||||||
|
|
||||||
// Should have single ? separator with params
|
|
||||||
let question_marks = url.matches('?').count();
|
|
||||||
assert_eq!(question_marks, 1);
|
|
||||||
|
|
||||||
// Should have params for maxWidth and maxHeight
|
|
||||||
assert!(url.contains("maxWidth=300"));
|
|
||||||
assert!(url.contains("maxHeight=200"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_special_characters_in_urls() {
|
|
||||||
let repo = MockOnlineRepository::new("https://server.com", "token_with_special-chars");
|
|
||||||
|
|
||||||
let url = repo.get_image_url("item-with-special_chars", "Primary", None);
|
|
||||||
|
|
||||||
// Should handle special characters in id (no token in URL anymore)
|
|
||||||
assert!(url.contains("item-with-special_chars"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Backend vs Frontend Responsibility Tests =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_backend_owns_url_construction() {
|
|
||||||
// This test documents that URL construction is ONLY in backend
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "secret_token");
|
|
||||||
|
|
||||||
// Backend generates full URL with credentials
|
|
||||||
let url = repo.get_image_url("item123", "Primary", None);
|
|
||||||
|
|
||||||
// URL is complete and ready to use (auth via header, not api_key)
|
|
||||||
assert!(url.starts_with("https://"));
|
|
||||||
assert!(url.contains("/Items/item123/Images/Primary"));
|
|
||||||
|
|
||||||
// Frontend never constructs URLs directly
|
|
||||||
// Frontend only receives pre-constructed URLs from backend
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_url_includes_all_necessary_parameters() {
|
|
||||||
let repo = MockOnlineRepository::new("https://server.com", "token");
|
|
||||||
let options = ImageOptions {
|
|
||||||
max_width: Some(300),
|
|
||||||
max_height: Some(200),
|
|
||||||
quality: Some(90),
|
|
||||||
tag: Some("abc".to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = repo.get_image_url("item123", "Primary", Some(&options));
|
|
||||||
|
|
||||||
// All provided options should be in URL
|
|
||||||
assert!(url.contains("maxWidth=300"));
|
|
||||||
assert!(url.contains("maxHeight=200"));
|
|
||||||
assert!(url.contains("quality=90"));
|
|
||||||
assert!(url.contains("tag=abc"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_optional_parameters_omitted_when_not_provided() {
|
|
||||||
let repo = MockOnlineRepository::new("https://server.com", "token");
|
|
||||||
let options = ImageOptions {
|
|
||||||
max_width: None,
|
|
||||||
max_height: None,
|
|
||||||
quality: None,
|
|
||||||
tag: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = repo.get_image_url("item123", "Primary", Some(&options));
|
|
||||||
|
|
||||||
// Should have no query params (no api_key, no options)
|
|
||||||
assert!(!url.contains("?"));
|
|
||||||
assert!(!url.contains("maxWidth"));
|
|
||||||
assert!(!url.contains("maxHeight"));
|
|
||||||
assert!(!url.contains("quality"));
|
|
||||||
assert!(!url.contains("tag"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
//! A fake Jellyfin server the online repository can actually talk to.
|
||||||
|
//!
|
||||||
|
//! # Why this exists
|
||||||
|
//!
|
||||||
|
//! Before it, `src-tauri/` contained no HTTP mocking of any kind. Every test of
|
||||||
|
//! the ~4,800-line online adapter asserted on a *constructed URL string*, and
|
||||||
|
//! not one exercised a response. That has a specific, recorded cost: a deleted
|
||||||
|
//! test file re-implemented the URL builders inside its own mock and then
|
||||||
|
//! asserted against itself, and `online.rs` still carries the comment recording
|
||||||
|
//! that the production builder meanwhile shipped a `/Videos/{id}/download`
|
||||||
|
//! endpoint which 404s on real servers — silently breaking every download while
|
||||||
|
//! the "test" stayed green.
|
||||||
|
//!
|
||||||
|
//! So the rule here is: **assert against a response from a mock *server*, never
|
||||||
|
//! against a mock that re-derives the thing under test.** Nothing in this module
|
||||||
|
//! may reimplement anything from `endpoints.rs` or `online.rs`.
|
||||||
|
//!
|
||||||
|
//! # Two generations
|
||||||
|
//!
|
||||||
|
//! [`FakeJellyfin::start`] takes the version string the fake server reports, and
|
||||||
|
//! the repository it hands back resolves its capabilities from exactly that — the
|
||||||
|
//! same path production takes. A test that runs against both generations is
|
||||||
|
//! therefore running the real resolution, not a stubbed one.
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-085 | DR-281
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
use wiremock::matchers::{method, path_regex};
|
||||||
|
use wiremock::{Mock, MockServer, Request, ResponseTemplate};
|
||||||
|
|
||||||
|
use super::capabilities::ServerCapabilities;
|
||||||
|
use super::online::OnlineRepository;
|
||||||
|
use crate::jellyfin::{HttpClient, HttpConfig};
|
||||||
|
|
||||||
|
/// Jellyfin's current stable line, and the line this client was built against.
|
||||||
|
pub const V12: &str = "12.0.0";
|
||||||
|
pub const V10_11: &str = "10.11.5";
|
||||||
|
|
||||||
|
/// Both live generations. `#[test]`s that care about compatibility iterate this.
|
||||||
|
///
|
||||||
|
/// There is no 11 in the middle: Jellyfin dropped the leading `10` from its
|
||||||
|
/// scheme with 12.0, so what would have been 10.12.0 shipped as `12.0`.
|
||||||
|
pub const BOTH_GENERATIONS: [&str; 2] = [V10_11, V12];
|
||||||
|
|
||||||
|
pub struct FakeJellyfin {
|
||||||
|
server: MockServer,
|
||||||
|
version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeJellyfin {
|
||||||
|
/// Stand up a server reporting `version`, answering any `/Items`-shaped
|
||||||
|
/// query with one item and any `/Users/.../Views` with one library.
|
||||||
|
///
|
||||||
|
/// The response bodies are deliberately minimal: this module's job is to let
|
||||||
|
/// tests observe what the *client* sent, not to re-specify Jellyfin.
|
||||||
|
pub async fn start(version: &str) -> Self {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
|
||||||
|
let item = json!({
|
||||||
|
"Id": "item-1",
|
||||||
|
"Name": "A Film",
|
||||||
|
"Type": "Movie",
|
||||||
|
"IsFolder": false,
|
||||||
|
"ServerId": "srv-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path_regex(r".*/Views$"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||||
|
"Items": [{
|
||||||
|
"Id": "lib-1",
|
||||||
|
"Name": "Movies",
|
||||||
|
"Type": "CollectionFolder",
|
||||||
|
"CollectionType": "movies",
|
||||||
|
"IsFolder": true,
|
||||||
|
"ServerId": "srv-1",
|
||||||
|
}],
|
||||||
|
"TotalRecordCount": 1,
|
||||||
|
})))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Everything else that returns a listing.
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||||
|
"Items": [item],
|
||||||
|
"TotalRecordCount": 1,
|
||||||
|
})))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.respond_with(ResponseTemplate::new(204))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Self {
|
||||||
|
server,
|
||||||
|
version: version.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A repository pointed at this server, with capabilities resolved from the
|
||||||
|
/// version it reports — the same resolution production performs.
|
||||||
|
pub fn repository(&self) -> OnlineRepository {
|
||||||
|
let http = HttpClient::new_allowing_plaintext_for_tests(HttpConfig::default())
|
||||||
|
.expect("test http client");
|
||||||
|
|
||||||
|
OnlineRepository::new(
|
||||||
|
Arc::new(http),
|
||||||
|
self.server.uri(),
|
||||||
|
"user-1".to_string(),
|
||||||
|
"token-abc".to_string(),
|
||||||
|
)
|
||||||
|
.with_capabilities(ServerCapabilities::from_reported(&self.version))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every request the server received, in order.
|
||||||
|
pub async fn requests(&self) -> Vec<Request> {
|
||||||
|
self.server
|
||||||
|
.received_requests()
|
||||||
|
.await
|
||||||
|
.expect("request recording is enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single request received, failing loudly if there was not exactly one.
|
||||||
|
pub async fn only_request(&self) -> Request {
|
||||||
|
let mut received = self.requests().await;
|
||||||
|
assert_eq!(
|
||||||
|
received.len(),
|
||||||
|
1,
|
||||||
|
"expected exactly one request, got {}",
|
||||||
|
received.len()
|
||||||
|
);
|
||||||
|
received.remove(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The request target as the server saw it — path plus query.
|
||||||
|
pub fn target(request: &Request) -> String {
|
||||||
|
match request.url.query() {
|
||||||
|
Some(q) => format!("{}?{}", request.url.path(), q),
|
||||||
|
None => request.url.path().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
//! - Test with different database backends
|
//! - Test with different database backends
|
||||||
//! - Migrate to other database systems in the future
|
//! - Migrate to other database systems in the future
|
||||||
|
|
||||||
|
use crate::utils::lock::MutexSafe;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use rusqlite::{params_from_iter, Connection, Result as SqliteResult, Row};
|
use rusqlite::{params_from_iter, Connection, Result as SqliteResult, Row};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
@@ -128,9 +129,10 @@ impl DatabaseService for RusqliteService {
|
|||||||
async fn execute(&self, query: Query) -> DbResult<usize> {
|
async fn execute(&self, query: Query) -> DbResult<usize> {
|
||||||
let conn = Arc::clone(&self.conn);
|
let conn = Arc::clone(&self.conn);
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let conn = conn
|
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
||||||
.lock()
|
// panic under the guard would otherwise poison it, failing every
|
||||||
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
// later query with "poisoned lock" until the process restarts.
|
||||||
|
let conn = conn.lock_safe();
|
||||||
execute_query(&conn, query)
|
execute_query(&conn, query)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -141,9 +143,10 @@ impl DatabaseService for RusqliteService {
|
|||||||
let conn = Arc::clone(&self.conn);
|
let conn = Arc::clone(&self.conn);
|
||||||
let sql = sql.to_string();
|
let sql = sql.to_string();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let conn = conn
|
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
||||||
.lock()
|
// panic under the guard would otherwise poison it, failing every
|
||||||
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
// later query with "poisoned lock" until the process restarts.
|
||||||
|
let conn = conn.lock_safe();
|
||||||
conn.execute_batch(&sql)
|
conn.execute_batch(&sql)
|
||||||
.map_err(|e| format!("Execute batch failed: {}", e))
|
.map_err(|e| format!("Execute batch failed: {}", e))
|
||||||
})
|
})
|
||||||
@@ -158,9 +161,10 @@ impl DatabaseService for RusqliteService {
|
|||||||
{
|
{
|
||||||
let conn = Arc::clone(&self.conn);
|
let conn = Arc::clone(&self.conn);
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let conn = conn
|
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
||||||
.lock()
|
// panic under the guard would otherwise poison it, failing every
|
||||||
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
// later query with "poisoned lock" until the process restarts.
|
||||||
|
let conn = conn.lock_safe();
|
||||||
query_one(&conn, query, mapper)
|
query_one(&conn, query, mapper)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -174,9 +178,10 @@ impl DatabaseService for RusqliteService {
|
|||||||
{
|
{
|
||||||
let conn = Arc::clone(&self.conn);
|
let conn = Arc::clone(&self.conn);
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let conn = conn
|
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
||||||
.lock()
|
// panic under the guard would otherwise poison it, failing every
|
||||||
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
// later query with "poisoned lock" until the process restarts.
|
||||||
|
let conn = conn.lock_safe();
|
||||||
query_optional(&conn, query, mapper)
|
query_optional(&conn, query, mapper)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -190,9 +195,10 @@ impl DatabaseService for RusqliteService {
|
|||||||
{
|
{
|
||||||
let conn = Arc::clone(&self.conn);
|
let conn = Arc::clone(&self.conn);
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let conn = conn
|
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
||||||
.lock()
|
// panic under the guard would otherwise poison it, failing every
|
||||||
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
// later query with "poisoned lock" until the process restarts.
|
||||||
|
let conn = conn.lock_safe();
|
||||||
query_many(&conn, query, mapper)
|
query_many(&conn, query, mapper)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -206,9 +212,10 @@ impl DatabaseService for RusqliteService {
|
|||||||
{
|
{
|
||||||
let conn = Arc::clone(&self.conn);
|
let conn = Arc::clone(&self.conn);
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let conn = conn
|
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
||||||
.lock()
|
// panic under the guard would otherwise poison it, failing every
|
||||||
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
// later query with "poisoned lock" until the process restarts.
|
||||||
|
let conn = conn.lock_safe();
|
||||||
|
|
||||||
conn.execute("BEGIN TRANSACTION", [])
|
conn.execute("BEGIN TRANSACTION", [])
|
||||||
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
|
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
|
||||||
@@ -236,9 +243,10 @@ impl DatabaseService for RusqliteService {
|
|||||||
async fn last_insert_rowid(&self) -> DbResult<i64> {
|
async fn last_insert_rowid(&self) -> DbResult<i64> {
|
||||||
let conn = Arc::clone(&self.conn);
|
let conn = Arc::clone(&self.conn);
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let conn = conn
|
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
||||||
.lock()
|
// panic under the guard would otherwise poison it, failing every
|
||||||
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
// later query with "poisoned lock" until the process restarts.
|
||||||
|
let conn = conn.lock_safe();
|
||||||
Ok(conn.last_insert_rowid())
|
Ok(conn.last_insert_rowid())
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -410,4 +418,48 @@ mod tests {
|
|||||||
let count: i32 = service.query_one(query, |row| row.get(0)).await.unwrap();
|
let count: i32 = service.query_one(query, |row| row.get(0)).await.unwrap();
|
||||||
assert_eq!(count, 2);
|
assert_eq!(count, 2);
|
||||||
}
|
}
|
||||||
|
/// A panic while the connection guard is held must not brick every later
|
||||||
|
/// query.
|
||||||
|
///
|
||||||
|
/// This is the single busiest lock in the app — every async DB operation
|
||||||
|
/// goes through it. With a raw `.lock()`, one panic under the guard poisons
|
||||||
|
/// the mutex and every subsequent call returns "poisoned lock" until the
|
||||||
|
/// process restarts, which for a database-backed app means the whole UI
|
||||||
|
/// stops working. `utils::lock` exists precisely to stop that cascade, and
|
||||||
|
/// `storage::Database` already used it; this path did not.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-002 | DR-012 | UT-014
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_poisoned_connection_still_serves_queries() {
|
||||||
|
let conn = Arc::new(Mutex::new(Connection::open_in_memory().unwrap()));
|
||||||
|
{
|
||||||
|
let c = conn.lock_safe();
|
||||||
|
c.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY);")
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poison the mutex the way a panicking row mapper would.
|
||||||
|
let poisoner = Arc::clone(&conn);
|
||||||
|
let hook = std::panic::take_hook();
|
||||||
|
std::panic::set_hook(Box::new(|_| {}));
|
||||||
|
let _ = std::thread::spawn(move || {
|
||||||
|
let _guard = poisoner.lock().unwrap();
|
||||||
|
panic!("a row mapper blew up while holding the connection");
|
||||||
|
})
|
||||||
|
.join();
|
||||||
|
std::panic::set_hook(hook);
|
||||||
|
assert!(conn.lock().is_err(), "the mutex should now be poisoned");
|
||||||
|
|
||||||
|
// Every operation must still work.
|
||||||
|
let service = RusqliteService::new(Arc::clone(&conn));
|
||||||
|
service
|
||||||
|
.execute(Query::new("INSERT INTO test (id) VALUES (1)"))
|
||||||
|
.await
|
||||||
|
.expect("execute must survive a poisoned connection");
|
||||||
|
let count: i32 = service
|
||||||
|
.query_one(Query::new("SELECT COUNT(*) FROM test"), |row| row.get(0))
|
||||||
|
.await
|
||||||
|
.expect("query_one must survive a poisoned connection");
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+139
-21
@@ -75,8 +75,31 @@ impl Database {
|
|||||||
Arc::clone(&self.conn)
|
Arc::clone(&self.conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run all pending migrations
|
/// Run all pending migrations.
|
||||||
pub fn migrate(&self) -> SqliteResult<()> {
|
pub fn migrate(&self) -> SqliteResult<()> {
|
||||||
|
self.migrate_with(MIGRATIONS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply `migrations` in order, skipping ones `_migrations` already records.
|
||||||
|
///
|
||||||
|
/// **Each migration is one transaction, and the `_migrations` row is written
|
||||||
|
/// inside it.** SQLite autocommits every statement otherwise, so a migration
|
||||||
|
/// that failed partway — low disk, an OOM kill, the process dying mid-boot —
|
||||||
|
/// used to leave its earlier statements applied while recording nothing.
|
||||||
|
/// `execute_batch` aborts on the first error, so the retry on the next launch
|
||||||
|
/// then failed at statement 1 ("duplicate column name") and kept failing
|
||||||
|
/// forever; `Database::open` turns that into a panic, so the app never
|
||||||
|
/// started again and the only fix was clearing app data. Committing the
|
||||||
|
/// schema change and the bookkeeping together makes a migration all-or-nothing
|
||||||
|
/// and a retry always safe.
|
||||||
|
///
|
||||||
|
/// Every migration is pure DDL/DML, which SQLite runs transactionally — a
|
||||||
|
/// `PRAGMA` or `VACUUM` added to one would not roll back and must not be.
|
||||||
|
///
|
||||||
|
/// Split out from [`Self::migrate`] so tests can inject a failing migration.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-002 | DR-012 | UT-014
|
||||||
|
fn migrate_with(&self, migrations: &[(&str, &str)]) -> SqliteResult<()> {
|
||||||
info!("Starting database migrations...");
|
info!("Starting database migrations...");
|
||||||
let conn = self.conn.lock_safe();
|
let conn = self.conn.lock_safe();
|
||||||
|
|
||||||
@@ -107,28 +130,30 @@ impl Database {
|
|||||||
debug!("Found {} applied migrations", applied.len());
|
debug!("Found {} applied migrations", applied.len());
|
||||||
|
|
||||||
// Apply pending migrations
|
// Apply pending migrations
|
||||||
for (name, sql) in MIGRATIONS {
|
for (name, sql) in migrations {
|
||||||
if !applied.contains(&name.to_string()) {
|
if applied.contains(&name.to_string()) {
|
||||||
info!("Applying migration: {}", name);
|
|
||||||
match conn.execute_batch(sql) {
|
|
||||||
Ok(_) => {
|
|
||||||
info!("Successfully applied migration: {}", name);
|
|
||||||
match conn.execute("INSERT INTO _migrations (name) VALUES (?1)", [name]) {
|
|
||||||
Ok(_) => debug!("Recorded migration: {}", name),
|
|
||||||
Err(e) => {
|
|
||||||
error!("Failed to record migration {}: {}", name, e);
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("Failed to apply migration {}: {}", name, e);
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
debug!("Skipping already applied migration: {}", name);
|
debug!("Skipping already applied migration: {}", name);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
info!("Applying migration: {}", name);
|
||||||
|
|
||||||
|
// `unchecked_transaction` because the connection is reached through a
|
||||||
|
// shared guard rather than `&mut`. Dropping the transaction without
|
||||||
|
// committing rolls it back, which is exactly what the `?`s below do.
|
||||||
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
|
||||||
|
if let Err(e) = tx.execute_batch(sql) {
|
||||||
|
error!("Failed to apply migration {} (rolled back): {}", name, e);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
if let Err(e) = tx.execute("INSERT INTO _migrations (name) VALUES (?1)", [name]) {
|
||||||
|
error!("Failed to record migration {} (rolled back): {}", name, e);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
|
||||||
|
info!("Successfully applied migration: {}", name);
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("All migrations completed successfully");
|
info!("All migrations completed successfully");
|
||||||
@@ -166,6 +191,99 @@ mod tests {
|
|||||||
assert_eq!(db.path().to_str(), Some(":memory:"));
|
assert_eq!(db.path().to_str(), Some(":memory:"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A migration that dies partway must leave *nothing* behind.
|
||||||
|
///
|
||||||
|
/// SQLite autocommits each statement, so before migrations were wrapped in a
|
||||||
|
/// transaction the first `ADD COLUMN` of a failing batch stuck while the
|
||||||
|
/// `_migrations` row was never written. `execute_batch` aborts on the first
|
||||||
|
/// error, so the retry on the next launch failed at statement 1 with
|
||||||
|
/// "duplicate column name" and kept failing forever — and `Database::open`
|
||||||
|
/// panics on that, so the app never started again.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-002 | DR-012 | UT-014
|
||||||
|
#[test]
|
||||||
|
fn test_failed_migration_rolls_back_and_stays_retryable() {
|
||||||
|
let db = Database::open_in_memory().unwrap();
|
||||||
|
|
||||||
|
// Statement 1 succeeds, statement 2 fails, statement 3 never runs.
|
||||||
|
let poisoned = &[(
|
||||||
|
"900_partially_failing",
|
||||||
|
"ALTER TABLE downloads ADD COLUMN audit_a TEXT;
|
||||||
|
ALTER TABLE downloads ADD COLUMN audit_b TEXT FROM NOWHERE;
|
||||||
|
ALTER TABLE downloads ADD COLUMN audit_c TEXT;",
|
||||||
|
)][..];
|
||||||
|
|
||||||
|
let first = db.migrate_with(poisoned).unwrap_err();
|
||||||
|
|
||||||
|
// Nothing from the batch may survive, or the retry cannot re-run it.
|
||||||
|
assert!(
|
||||||
|
!has_column(&db, "downloads", "audit_a"),
|
||||||
|
"statement 1 of a failed migration was left applied: the batch did not roll back"
|
||||||
|
);
|
||||||
|
assert!(!has_column(&db, "downloads", "audit_c"));
|
||||||
|
|
||||||
|
// And it must not be recorded as applied.
|
||||||
|
let recorded: i64 = db
|
||||||
|
.connection()
|
||||||
|
.lock_safe()
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM _migrations WHERE name = ?1",
|
||||||
|
["900_partially_failing"],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(recorded, 0, "a failed migration must not be recorded");
|
||||||
|
|
||||||
|
// The retry must fail the same way it did the first time — reaching the
|
||||||
|
// real error — rather than tripping over its own leftovers.
|
||||||
|
let second = db.migrate_with(poisoned).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
!second.to_string().contains("duplicate column"),
|
||||||
|
"the retry hit leftovers from the failed run instead of the real error: {second}"
|
||||||
|
);
|
||||||
|
assert_eq!(first.to_string(), second.to_string());
|
||||||
|
|
||||||
|
// A corrected migration under the same name then applies cleanly.
|
||||||
|
let fixed = &[(
|
||||||
|
"900_partially_failing",
|
||||||
|
"ALTER TABLE downloads ADD COLUMN audit_a TEXT;
|
||||||
|
ALTER TABLE downloads ADD COLUMN audit_c TEXT;",
|
||||||
|
)][..];
|
||||||
|
db.migrate_with(fixed).unwrap();
|
||||||
|
assert!(has_column(&db, "downloads", "audit_a"));
|
||||||
|
assert!(has_column(&db, "downloads", "audit_c"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A committed migration is recorded, so it is never applied twice.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-002 | DR-012 | UT-014
|
||||||
|
#[test]
|
||||||
|
fn test_successful_migration_is_recorded_in_the_same_transaction() {
|
||||||
|
let db = Database::open_in_memory().unwrap();
|
||||||
|
let m = &[(
|
||||||
|
"901_adds_a_column",
|
||||||
|
"ALTER TABLE downloads ADD COLUMN audit_d TEXT;",
|
||||||
|
)][..];
|
||||||
|
|
||||||
|
db.migrate_with(m).unwrap();
|
||||||
|
// Re-running must be a no-op, not a "duplicate column" failure.
|
||||||
|
db.migrate_with(m).unwrap();
|
||||||
|
assert!(has_column(&db, "downloads", "audit_d"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_column(db: &Database, table: &str, column: &str) -> bool {
|
||||||
|
let conn = db.connection();
|
||||||
|
let conn = conn.lock_safe();
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(&format!("PRAGMA table_info({table})"))
|
||||||
|
.unwrap();
|
||||||
|
let mut names = stmt
|
||||||
|
.query_map([], |row| row.get::<_, String>(1))
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|r| r.ok());
|
||||||
|
names.any(|n| n == column)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_migrations_run() {
|
fn test_migrations_run() {
|
||||||
let db = Database::open_in_memory().unwrap();
|
let db = Database::open_in_memory().unwrap();
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
|
|||||||
("021_rebuild_items_fts", MIGRATION_021),
|
("021_rebuild_items_fts", MIGRATION_021),
|
||||||
("022_people_fts", MIGRATION_022),
|
("022_people_fts", MIGRATION_022),
|
||||||
("023_downloads_expiry", MIGRATION_023),
|
("023_downloads_expiry", MIGRATION_023),
|
||||||
|
("024_multi_user_profiles", MIGRATION_024),
|
||||||
|
("025_backfill_item_library_id", MIGRATION_025),
|
||||||
|
("026_server_catalog_generation", MIGRATION_026),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Initial schema migration
|
/// Initial schema migration
|
||||||
@@ -819,3 +822,293 @@ ALTER TABLE downloads ADD COLUMN expires_at TEXT;
|
|||||||
CREATE INDEX IF NOT EXISTS idx_downloads_expiry
|
CREATE INDEX IF NOT EXISTS idx_downloads_expiry
|
||||||
ON downloads(download_source, expires_at);
|
ON downloads(download_source, expires_at);
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
/// Multi-user profiles: PIN gate, per-user cache visibility, and download grants.
|
||||||
|
///
|
||||||
|
/// Three tables, one purpose each:
|
||||||
|
///
|
||||||
|
/// - `user_pins` holds the switching gate. The PIN hash lives here rather than
|
||||||
|
/// wrapping the access token, because a wrapped token would leave a locked
|
||||||
|
/// profile unable to resume its own downloads or drain its own sync queue
|
||||||
|
/// until someone typed the code. See DR-268 for why that trade was taken.
|
||||||
|
/// - `user_item_visibility` records what the server has actually shown to each
|
||||||
|
/// user. It is written as a byproduct of the cache write path, never rebuilt,
|
||||||
|
/// so it cannot disagree with what the server returned.
|
||||||
|
/// - `download_grants` separates the bytes from the claim on them, so one file
|
||||||
|
/// can serve several profiles and is unlinked only when the last claim goes.
|
||||||
|
///
|
||||||
|
/// The backfill is not optional. Every existing cache row and download predates
|
||||||
|
/// the concept of a user; without it an upgrading install's library goes blank.
|
||||||
|
/// It grants the *active* user only — other pre-existing rows re-populate from
|
||||||
|
/// the server on next browse, which is strictly safer than handing every
|
||||||
|
/// profile the whole cache. The `OR (SELECT COUNT(*) ...) = 0` arm covers an
|
||||||
|
/// install whose single user somehow has `is_active = 0`.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-082, UR-083 | DR-268, DR-271, DR-272
|
||||||
|
const MIGRATION_024: &str = r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS user_pins (
|
||||||
|
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
pin_hash TEXT NOT NULL,
|
||||||
|
failed_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
locked_until TEXT,
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_item_visibility (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, item_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_visibility_user ON user_item_visibility(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_libraries (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
library_id TEXT NOT NULL,
|
||||||
|
seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, library_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS download_grants (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
download_id INTEGER NOT NULL REFERENCES downloads(id) ON DELETE CASCADE,
|
||||||
|
granted_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, download_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_download_grants_download ON download_grants(download_id);
|
||||||
|
|
||||||
|
-- Backfill: the active user has seen everything already cached on this device.
|
||||||
|
INSERT OR IGNORE INTO user_item_visibility (user_id, item_id)
|
||||||
|
SELECT u.id, i.id
|
||||||
|
FROM users u CROSS JOIN items i
|
||||||
|
WHERE u.is_active = 1
|
||||||
|
OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0;
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO user_libraries (user_id, library_id)
|
||||||
|
SELECT u.id, l.id
|
||||||
|
FROM users u CROSS JOIN libraries l
|
||||||
|
WHERE u.is_active = 1
|
||||||
|
OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0;
|
||||||
|
|
||||||
|
-- Downloads already record who asked for them, so every existing row becomes
|
||||||
|
-- exactly one grant held by its original requester.
|
||||||
|
INSERT OR IGNORE INTO download_grants (user_id, download_id)
|
||||||
|
SELECT d.user_id, d.id FROM downloads d;
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Force cached items to be re-fetched so `library_id` is populated.
|
||||||
|
///
|
||||||
|
/// `save_to_cache` bound `library_id` NULL for every row it wrote, so nothing in
|
||||||
|
/// the cache knew which library it came from. The only available association was
|
||||||
|
/// the `collection_type` ↔ `item_type` taxonomy, which cannot tell two libraries
|
||||||
|
/// of the same type apart — a server with "TV" and "Shows" served both the same
|
||||||
|
/// contents — and says nothing at all about a library whose type it does not map
|
||||||
|
/// (Books, Photos, Collections, or a mixed library where Jellyfin sends no
|
||||||
|
/// collection type).
|
||||||
|
///
|
||||||
|
/// The write path now records the library. Existing rows cannot be repaired
|
||||||
|
/// locally — the association was never stored — so they are marked stale and
|
||||||
|
/// re-fetched on next browse, exactly as MIGRATION_018 did for `is_folder`.
|
||||||
|
///
|
||||||
|
/// Deliberately does not delete anything: downloads, favourites and playback
|
||||||
|
/// positions live in other tables and are untouched, and a cleared `synced_at`
|
||||||
|
/// only means "ask the server again", so an offline user keeps browsing what
|
||||||
|
/// they already had until the next successful fetch.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-007 | DR-278
|
||||||
|
const MIGRATION_025: &str = r#"
|
||||||
|
UPDATE items SET synced_at = NULL;
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Remember which server generation wrote the cached catalog.
|
||||||
|
///
|
||||||
|
/// The cache was version-blind: nothing recorded which Jellyfin generation
|
||||||
|
/// produced a row, so a server upgraded underneath the app kept serving rows
|
||||||
|
/// parsed under the previous generation's assumptions.
|
||||||
|
///
|
||||||
|
/// This deliberately does **not** clear `synced_at` the way MIGRATION_025 did.
|
||||||
|
/// The column starts NULL, which reads as "no generation recorded yet", and the
|
||||||
|
/// first connection after upgrading simply records what it finds. Invalidation
|
||||||
|
/// happens only when the recorded generation actually *changes* — punishing
|
||||||
|
/// every existing user with a full re-fetch for a server upgrade that has not
|
||||||
|
/// happened would cost real bandwidth to defend against nothing. At the time of
|
||||||
|
/// writing no installed server is on the newer generation at all.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-085 | DR-284
|
||||||
|
const MIGRATION_026: &str = r#"
|
||||||
|
ALTER TABLE servers ADD COLUMN catalog_generation TEXT;
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[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",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "JellyTau",
|
"productName": "JellyTau",
|
||||||
"version": "0.11.5",
|
"version": "0.12.0",
|
||||||
"identifier": "com.dtourolle.jellytau",
|
"identifier": "com.dtourolle.jellytau",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
+217
-1
@@ -1826,6 +1826,107 @@ async playlistGetItems(handle: string, playlistId: string) : Promise<PlaylistEnt
|
|||||||
async playlistAddItems(handle: string, playlistId: string, itemIds: string[]) : Promise<null> {
|
async playlistAddItems(handle: string, playlistId: string, itemIds: string[]) : Promise<null> {
|
||||||
return await TAURI_INVOKE("playlist_add_items", { handle, playlistId, itemIds });
|
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)
|
* Remove items from a playlist (uses PlaylistItemId entry IDs, NOT media item IDs)
|
||||||
*/
|
*/
|
||||||
@@ -2034,7 +2135,18 @@ export type AuthServerInfo = { name: string; version: string; id: string;
|
|||||||
/**
|
/**
|
||||||
* Normalized server URL with protocol and no trailing slash
|
* Normalized server URL with protocol and no trailing slash
|
||||||
*/
|
*/
|
||||||
normalizedUrl: string }
|
normalizedUrl: string;
|
||||||
|
/**
|
||||||
|
* Whether this build can talk to this server, as an **opaque state**.
|
||||||
|
*
|
||||||
|
* The version string above is informational — for display and for the log.
|
||||||
|
* This is the judgement, made in Rust, because deciding whether an API
|
||||||
|
* version is usable is domain reasoning: the frontend must never compare a
|
||||||
|
* version number, for the same reason it never receives an item-type list.
|
||||||
|
*
|
||||||
|
* TRACES: UR-085 | DR-286
|
||||||
|
*/
|
||||||
|
compatibility: ServerCompatibility }
|
||||||
/**
|
/**
|
||||||
* Autoplay settings (controls next episode behavior)
|
* Autoplay settings (controls next episode behavior)
|
||||||
*/
|
*/
|
||||||
@@ -3198,6 +3310,16 @@ alreadyDownloaded: number;
|
|||||||
* Number of tracks skipped (no jellyfin ID or other reasons)
|
* Number of tracks skipped (no jellyfin ID or other reasons)
|
||||||
*/
|
*/
|
||||||
skipped: number }
|
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.
|
* One rung of the quality picker, as it applies to *this* media source.
|
||||||
*
|
*
|
||||||
@@ -3317,6 +3439,37 @@ export type SecurityStatus = { usingKeyring: boolean; storageType: string }
|
|||||||
* Audio track preference for a series
|
* Audio track preference for a series
|
||||||
*/
|
*/
|
||||||
export type SeriesAudioPreference = { seriesId: string; audioTrackDisplayTitle: string | null; audioTrackLanguage: string | null; audioTrackIndex: number | null }
|
export type SeriesAudioPreference = { seriesId: string; audioTrackDisplayTitle: string | null; audioTrackLanguage: string | null; audioTrackIndex: number | null }
|
||||||
|
/**
|
||||||
|
* The verdict on a server's version.
|
||||||
|
*
|
||||||
|
* Deliberately three states rather than a boolean. "Unrecognised" is not a
|
||||||
|
* failure: a server newer than this build resolves forward and works, and
|
||||||
|
* refusing it would make every JellyTau release expire the moment the server
|
||||||
|
* upgrades. Only a server below the supported floor is refused, where failure
|
||||||
|
* is certain rather than merely likely.
|
||||||
|
*
|
||||||
|
* TRACES: UR-085 | DR-286
|
||||||
|
*/
|
||||||
|
export type ServerCompatibility =
|
||||||
|
/**
|
||||||
|
* A generation this build knows and was tested against.
|
||||||
|
*/
|
||||||
|
{ type: "supported" } |
|
||||||
|
/**
|
||||||
|
* Parsed, but newer than anything this build knows. Treated as the newest
|
||||||
|
* known generation; everything works, and this exists so the UI *may*
|
||||||
|
* mention it rather than so it must.
|
||||||
|
*/
|
||||||
|
{ type: "newerThanKnown" } |
|
||||||
|
/**
|
||||||
|
* The version string could not be parsed. Treated as supported — we do not
|
||||||
|
* refuse a server on the strength of not understanding its version string.
|
||||||
|
*/
|
||||||
|
{ type: "unknownVersion" } |
|
||||||
|
/**
|
||||||
|
* Below the supported floor. This one is a refusal.
|
||||||
|
*/
|
||||||
|
{ type: "tooOld"; minimum: string }
|
||||||
/**
|
/**
|
||||||
* Server info returned to frontend
|
* Server info returned to frontend
|
||||||
*/
|
*/
|
||||||
@@ -3358,6 +3511,25 @@ export type SleepTimerState = { mode: SleepTimerMode; remainingSeconds: number }
|
|||||||
* SmartCache statistics
|
* SmartCache statistics
|
||||||
*/
|
*/
|
||||||
export type SmartCacheStats = { total_size: number; storage_limit: number; available_space: number; items_count: number; config: CacheConfig }
|
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
|
* Storage statistics for downloads
|
||||||
*/
|
*/
|
||||||
@@ -3558,6 +3730,50 @@ export type Transport =
|
|||||||
* server standing in front of one.
|
* server standing in front of one.
|
||||||
*/
|
*/
|
||||||
{ type: "localFile" }
|
{ 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
|
* 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,
|
Settings, Display) and Sign out. Available on every authenticated,
|
||||||
non-immersive screen via the shared AppHeader.
|
non-immersive screen via the shared AppHeader.
|
||||||
|
|
||||||
TRACES: UR-054 | DR-075
|
TRACES: UR-054, UR-082 | DR-075, DR-276
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
@@ -105,6 +105,32 @@
|
|||||||
|
|
||||||
<div class="border-t border-gray-700 my-1"></div>
|
<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
|
<a
|
||||||
href="/downloads"
|
href="/downloads"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
|||||||
@@ -73,7 +73,22 @@ describe("AccountMenu", () => {
|
|||||||
render(AccountMenu);
|
render(AccountMenu);
|
||||||
openMenu();
|
openMenu();
|
||||||
const items = screen.getAllByRole("menuitem").map((el) => el.textContent?.trim());
|
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 () => {
|
it("shows the identity block with name and server host", async () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
getCachedImageUrl,
|
|
||||||
getCacheStats,
|
getCacheStats,
|
||||||
setCacheLimit,
|
setCacheLimit,
|
||||||
clearCache,
|
clearCache,
|
||||||
@@ -50,43 +49,6 @@ describe("image cache service", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getCachedImageUrl", () => {
|
|
||||||
it("should build server URL with default image type", async () => {
|
|
||||||
const url = await getCachedImageUrl("http://server.local:8096", "item-123");
|
|
||||||
expect(url).toContain("http://server.local:8096/Items/item-123/Images/Primary");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should build server URL with custom image type", async () => {
|
|
||||||
const url = await getCachedImageUrl("http://server.local:8096", "item-123", "Backdrop");
|
|
||||||
expect(url).toContain("Backdrop");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should include image options in URL", async () => {
|
|
||||||
const url = await getCachedImageUrl("http://server.local:8096", "item-123", "Primary", {
|
|
||||||
maxWidth: 300,
|
|
||||||
maxHeight: 400,
|
|
||||||
quality: 90,
|
|
||||||
tag: "abc123",
|
|
||||||
});
|
|
||||||
expect(url).toContain("maxWidth=300");
|
|
||||||
expect(url).toContain("maxHeight=400");
|
|
||||||
expect(url).toContain("quality=90");
|
|
||||||
expect(url).toContain("tag=abc123");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should trigger background caching", async () => {
|
|
||||||
const { invoke } = await import("@tauri-apps/api/core");
|
|
||||||
const invokeSpy = vi.mocked(invoke);
|
|
||||||
|
|
||||||
await getCachedImageUrl("http://server.local:8096", "item-123");
|
|
||||||
|
|
||||||
const saveCall = invokeSpy.mock.calls.find((call) => call[0] === "thumbnail_save");
|
|
||||||
expect(saveCall).toBeDefined();
|
|
||||||
expect(saveCall![1]).toHaveProperty("itemId", "item-123");
|
|
||||||
expect(saveCall![1]).toHaveProperty("imageType", "Primary");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("cache statistics", () => {
|
describe("cache statistics", () => {
|
||||||
it("should get cache statistics", async () => {
|
it("should get cache statistics", async () => {
|
||||||
const stats = await getCacheStats();
|
const stats = await getCacheStats();
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
// Image cache service - Handles lazy caching of thumbnails with LRU eviction
|
// Image cache service — cache statistics, limits and eviction.
|
||||||
// TRACES: UR-007 | DR-016
|
//
|
||||||
|
// This module used to also export `getCachedImageUrl`, which built
|
||||||
|
// `${serverUrl}/Items/${itemId}/Images/${imageType}` in the frontend. That was a
|
||||||
|
// Jellyfin route in the presentation layer — domain logic by this project's own
|
||||||
|
// litmus test (would it change if Jellyfin changed its API?) — and it was
|
||||||
|
// **dead**: nothing outside this file and its test ever called it. The live path
|
||||||
|
// is CachedImage.svelte -> commands.imageGetUrl -> Rust, which was already
|
||||||
|
// correct. It was deleted rather than migrated (DR-285).
|
||||||
|
//
|
||||||
|
// Note for whoever touches the CSP next: that function was the last
|
||||||
|
// `convertFileSrc` caller, so the asset-protocol grant narrowed to
|
||||||
|
// `$APPDATA/thumbnails/**` under DR-198 now has no caller at all and is a
|
||||||
|
// candidate for removal. Left in place here deliberately — dropping a capability
|
||||||
|
// grant is a security change that deserves its own commit and its own testing on
|
||||||
|
// Android, not a side effect of deleting dead code.
|
||||||
|
//
|
||||||
|
// TRACES: UR-007, UR-085 | DR-016, DR-285
|
||||||
|
|
||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
import { createLogger } from "$lib/utils/logger";
|
|
||||||
|
|
||||||
const log = createLogger("ImageCache");
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Statistics about the thumbnail cache
|
* Statistics about the thumbnail cache
|
||||||
@@ -16,63 +28,6 @@ export interface ImageCacheStats {
|
|||||||
limitBytes: number;
|
limitBytes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get an image URL, checking cache first then falling back to server.
|
|
||||||
* Triggers background caching if not cached.
|
|
||||||
*
|
|
||||||
* @param serverUrl - The Jellyfin server base URL
|
|
||||||
* @param itemId - The Jellyfin item ID
|
|
||||||
* @param imageType - The image type (Primary, Backdrop, etc.)
|
|
||||||
* @param options - Image options (maxWidth, maxHeight, quality, tag)
|
|
||||||
* @returns The image URL (local asset URL if cached, server URL otherwise)
|
|
||||||
*/
|
|
||||||
export async function getCachedImageUrl(
|
|
||||||
serverUrl: string,
|
|
||||||
itemId: string,
|
|
||||||
imageType: string = "Primary",
|
|
||||||
options: {
|
|
||||||
maxWidth?: number;
|
|
||||||
maxHeight?: number;
|
|
||||||
quality?: number;
|
|
||||||
tag?: string;
|
|
||||||
} = {},
|
|
||||||
): Promise<string> {
|
|
||||||
const tag = options.tag || "default";
|
|
||||||
|
|
||||||
// Try to get cached version
|
|
||||||
try {
|
|
||||||
const cachedPath = await commands.thumbnailGetCached(itemId, imageType, tag);
|
|
||||||
|
|
||||||
if (cachedPath) {
|
|
||||||
// Convert file path to asset URL for Tauri. This is the only remaining
|
|
||||||
// convertFileSrc caller, which is why the asset-protocol scope is narrowed
|
|
||||||
// to $APPDATA/thumbnails/** — a path outside it resolves to nothing.
|
|
||||||
// TRACES: UR-012 | DR-134, DR-198
|
|
||||||
return convertFileSrc(cachedPath);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
log.debug("Failed to check thumbnail cache:", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build server URL
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (options.maxWidth) params.set("maxWidth", options.maxWidth.toString());
|
|
||||||
if (options.maxHeight) params.set("maxHeight", options.maxHeight.toString());
|
|
||||||
if (options.quality) params.set("quality", options.quality.toString());
|
|
||||||
if (options.tag) params.set("tag", options.tag);
|
|
||||||
|
|
||||||
const serverImageUrl = `${serverUrl}/Items/${itemId}/Images/${imageType}?${params.toString()}`;
|
|
||||||
|
|
||||||
// Trigger background caching (fire and forget)
|
|
||||||
commands.thumbnailSave(itemId, imageType, tag, serverImageUrl).catch((e) => {
|
|
||||||
// Silently fail - caching is best-effort
|
|
||||||
log.debug("Background thumbnail cache failed:", e);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return server URL for immediate display
|
|
||||||
return serverImageUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get thumbnail cache statistics
|
* Get thumbnail cache statistics
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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 {
|
return {
|
||||||
subscribe,
|
subscribe,
|
||||||
initialize,
|
initialize,
|
||||||
@@ -620,6 +680,7 @@ function createAuthStore() {
|
|||||||
getUserId,
|
getUserId,
|
||||||
getServerUrl,
|
getServerUrl,
|
||||||
retryVerification,
|
retryVerification,
|
||||||
|
adoptSwitchedSession,
|
||||||
cleanupEventListeners,
|
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);
|
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
|
* 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 {
|
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 (
|
return (
|
||||||
!pathname.startsWith("/player/") &&
|
!pathname.startsWith("/player/") &&
|
||||||
!pathname.startsWith("/login") &&
|
!pathname.startsWith("/login") &&
|
||||||
|
!pathname.startsWith("/profiles") &&
|
||||||
!pathname.startsWith("/settings")
|
!pathname.startsWith("/settings")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -58,7 +68,8 @@ export function routeOwnsLayout({ pathname }: { pathname: string }): boolean {
|
|||||||
return (
|
return (
|
||||||
pathname.startsWith("/library") ||
|
pathname.startsWith("/library") ||
|
||||||
pathname.startsWith("/player/") ||
|
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
|
* 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
|
* 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
|
* 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.
|
* `/downloads`) gets the header from the root — the whole point of UR-054.
|
||||||
*
|
*
|
||||||
* TRACES: UR-054 | DR-076
|
* 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,
|
* Exactly one element may reserve it. BottomUi owns it whenever it renders,
|
||||||
* because the padding belongs *inside* its surface box so the colour extends
|
* 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
|
* 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.
|
* the shell takes it.
|
||||||
*
|
*
|
||||||
* TRACES: UR-066 | DR-112
|
* 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`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* TRACES: UR-085 | DR-286
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { compatibilityNotice } from "./serverCompatibility";
|
||||||
|
|
||||||
|
describe("server compatibility notice", () => {
|
||||||
|
it("says nothing about a supported server", () => {
|
||||||
|
expect(compatibilityNotice({ type: "supported" }, "12.0.0")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not interrupt anyone over a version it could not parse", () => {
|
||||||
|
// Refusing, or even warning, on an unreadable version string would punish
|
||||||
|
// the user for a parsing limitation of ours.
|
||||||
|
expect(compatibilityNotice({ type: "unknownVersion" }, "weird-build")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mentions a newer-than-known server without blocking it", () => {
|
||||||
|
const notice = compatibilityNotice({ type: "newerThanKnown" }, "13.0.0");
|
||||||
|
expect(notice).not.toBeNull();
|
||||||
|
expect(notice!.blocking).toBe(false);
|
||||||
|
expect(notice!.tone).toBe("warning");
|
||||||
|
expect(notice!.message).toContain("13.0.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks a server below the floor and names the floor", () => {
|
||||||
|
const notice = compatibilityNotice({ type: "tooOld", minimum: "10.10" }, "10.9.11");
|
||||||
|
expect(notice).not.toBeNull();
|
||||||
|
expect(notice!.blocking).toBe(true);
|
||||||
|
expect(notice!.tone).toBe("error");
|
||||||
|
expect(notice!.message).toContain("10.9.11");
|
||||||
|
expect(notice!.message).toContain("10.10");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Presentation of the backend's server-compatibility verdict.
|
||||||
|
//
|
||||||
|
// The decision is Rust's — see `ServerCompatibility` in `auth/mod.rs`. This file
|
||||||
|
// decides only how it *reads*, which is presentation and changes only if the UI
|
||||||
|
// is redesigned. Nothing here compares a version number, and nothing here may
|
||||||
|
// start to: the backend sends an opaque state precisely so the frontend cannot.
|
||||||
|
//
|
||||||
|
// TRACES: UR-085 | DR-286
|
||||||
|
|
||||||
|
import type { ServerCompatibility } from "$lib/api/bindings";
|
||||||
|
|
||||||
|
export interface CompatibilityNotice {
|
||||||
|
/** Blocks going on to the login step. Only a server below the floor does. */
|
||||||
|
blocking: boolean;
|
||||||
|
tone: "error" | "warning";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to show the user about a server's version, or `null` when there is
|
||||||
|
* nothing worth saying — which is the common case.
|
||||||
|
*/
|
||||||
|
export function compatibilityNotice(
|
||||||
|
compatibility: ServerCompatibility,
|
||||||
|
serverVersion: string,
|
||||||
|
): CompatibilityNotice | null {
|
||||||
|
switch (compatibility.type) {
|
||||||
|
case "supported":
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case "unknownVersion":
|
||||||
|
// Not worth interrupting anyone over: the server almost certainly works,
|
||||||
|
// and we simply could not read what it called itself.
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case "newerThanKnown":
|
||||||
|
return {
|
||||||
|
blocking: false,
|
||||||
|
tone: "warning",
|
||||||
|
message:
|
||||||
|
`This server (${serverVersion}) is newer than this version of JellyTau. ` +
|
||||||
|
`It should work normally — update the app if anything looks wrong.`,
|
||||||
|
};
|
||||||
|
|
||||||
|
case "tooOld":
|
||||||
|
return {
|
||||||
|
blocking: true,
|
||||||
|
tone: "error",
|
||||||
|
message:
|
||||||
|
`This server runs Jellyfin ${serverVersion}. JellyTau needs ` +
|
||||||
|
`${compatibility.minimum} or newer.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
-3
@@ -3,6 +3,7 @@
|
|||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { platform } from "@tauri-apps/plugin-os";
|
import { platform } from "@tauri-apps/plugin-os";
|
||||||
import { auth, isAuthenticated } from "$lib/stores/auth";
|
import { auth, isAuthenticated } from "$lib/stores/auth";
|
||||||
|
import { profiles } from "$lib/stores/profiles";
|
||||||
import { home } from "$lib/stores/home";
|
import { home } from "$lib/stores/home";
|
||||||
import { library, libraries } from "$lib/stores/library";
|
import { library, libraries } from "$lib/stores/library";
|
||||||
import { isServerReachable } from "$lib/stores/connectivity";
|
import { isServerReachable } from "$lib/stores/connectivity";
|
||||||
@@ -29,11 +30,31 @@
|
|||||||
let previousServerReachable = false;
|
let previousServerReachable = false;
|
||||||
let isAndroid = $state(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(() => {
|
$effect(() => {
|
||||||
if (!$isAuthenticated) {
|
if ($isAuthenticated || routingAway) return;
|
||||||
goto("/login");
|
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
|
// Load home sections when authenticated
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { auth, isAuthenticated, isLoading, authError } from "$lib/stores/auth";
|
import { auth, isAuthenticated, isLoading, authError } from "$lib/stores/auth";
|
||||||
|
import { compatibilityNotice } from "$lib/utils/serverCompatibility";
|
||||||
|
|
||||||
let step = $state<"server" | "login">("server");
|
let step = $state<"server" | "login">("server");
|
||||||
let serverUrl = $state("");
|
let serverUrl = $state("");
|
||||||
@@ -11,6 +12,8 @@
|
|||||||
let connecting = $state(false);
|
let connecting = $state(false);
|
||||||
let loggingIn = $state(false);
|
let loggingIn = $state(false);
|
||||||
let localError = $state<string | null>(null);
|
let localError = $state<string | null>(null);
|
||||||
|
/// Non-blocking note about the server version (e.g. newer than this build).
|
||||||
|
let serverNotice = $state<string | null>(null);
|
||||||
|
|
||||||
// Redirect to library if already authenticated
|
// Redirect to library if already authenticated
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -36,6 +39,16 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const info = await auth.connectToServer(serverUrl);
|
const info = await auth.connectToServer(serverUrl);
|
||||||
|
|
||||||
|
// The backend decided whether this server's version is usable; we only
|
||||||
|
// render its verdict. TRACES: UR-085 | DR-286
|
||||||
|
const notice = compatibilityNotice(info.compatibility, info.version);
|
||||||
|
if (notice?.blocking) {
|
||||||
|
localError = notice.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
serverNotice = notice?.message ?? null;
|
||||||
|
|
||||||
serverName = info.name;
|
serverName = info.name;
|
||||||
serverUrl = info.normalizedUrl; // Use normalized URL with https://
|
serverUrl = info.normalizedUrl; // Use normalized URL with https://
|
||||||
step = "login";
|
step = "login";
|
||||||
@@ -224,6 +237,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if serverNotice}
|
||||||
|
<div
|
||||||
|
class="p-3 bg-amber-900/40 border border-amber-700 rounded-lg text-amber-200 text-sm"
|
||||||
|
>
|
||||||
|
{serverNotice}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if localError || $authError}
|
{#if localError || $authError}
|
||||||
<div class="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
|
<div class="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
|
||||||
{localError || $authError}
|
{localError || $authError}
|
||||||
|
|||||||
@@ -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">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from "svelte";
|
import { onDestroy, onMount } from "svelte";
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
|
import { profiles } from "$lib/stores/profiles";
|
||||||
|
import ProfileSecuritySettings from "$lib/components/settings/ProfileSecuritySettings.svelte";
|
||||||
import type {
|
import type {
|
||||||
AudioSettings,
|
AudioSettings,
|
||||||
CacheConfig,
|
CacheConfig,
|
||||||
@@ -147,8 +149,13 @@
|
|||||||
// Promise and Svelte would never invoke it as a teardown.
|
// Promise and Svelte would never invoke it as a teardown.
|
||||||
onDestroy(unsubscribeNativeVideo);
|
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 () => {
|
onMount(async () => {
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
|
askOnStart = await commands.profilesGetAskOnStart();
|
||||||
supportsNativeVideo = (await getPlaybackCapabilities()).supportsNativeVideo;
|
supportsNativeVideo = (await getPlaybackCapabilities()).supportsNativeVideo;
|
||||||
|
|
||||||
// Which update story this platform gets. Android cannot install its own
|
// Which update story this platform gets. Android cannot install its own
|
||||||
@@ -985,6 +992,46 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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 -->
|
<!-- Search Settings -->
|
||||||
<div class="border-t border-gray-700 pt-6">
|
<div class="border-t border-gray-700 pt-6">
|
||||||
<h2 class="text-2xl font-bold text-white mb-4">Search</h2>
|
<h2 class="text-2xl font-bold text-white mb-4">Search</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user