Compare commits
53
Commits
@@ -1,92 +0,0 @@
|
|||||||
name: android
|
|
||||||
|
|
||||||
# Native Android (Kotlin/Compose/Media3) — M8 rewrite, now the only client.
|
|
||||||
# This workflow is testing only — lint + detekt + unit tests on every push
|
|
||||||
# to dev/main, plus a debug APK artifact for main. The signed-release
|
|
||||||
# build + asset attach + image-bundling lives in release.yml under a
|
|
||||||
# `needs:` chain so the docker image cannot ship without the APK.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, dev]
|
|
||||||
paths:
|
|
||||||
- 'android/**'
|
|
||||||
- '.gitea/workflows/android.yml'
|
|
||||||
|
|
||||||
# pull_request trigger intentionally omitted — see test-web.yml for
|
|
||||||
# the rationale (single-author repo, push covers PR-merge equivalent).
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
env:
|
|
||||||
# Silences the JDK 22+ "restricted method in java.lang.System has been
|
|
||||||
# called" warning that Gradle 9.1's bundled native-platform jar trips
|
|
||||||
# at launch (System.load for native primitives). Affects the LAUNCHER
|
|
||||||
# JVM, not the daemon — that's why org.gradle.jvmargs in
|
|
||||||
# gradle.properties isn't enough. Future-compat: required opt-in once
|
|
||||||
# JDK 25 promotes the warning to an error.
|
|
||||||
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build + lint + test
|
|
||||||
# Using flutter-ci runner label because it's the only proven-working
|
|
||||||
# label with docker that can pull our container.image. Switch to
|
|
||||||
# android-ci once the operator registers that runner label.
|
|
||||||
runs-on: flutter-ci
|
|
||||||
container:
|
|
||||||
image: git.fabledsword.com/bvandeusen/ci-android:36
|
|
||||||
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: android
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Cache Gradle dirs
|
|
||||||
# Resolved deps + Gradle distribution + Kotlin daemon caches.
|
|
||||||
# Saves ~3 min per CI run after the first warm-up.
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.gradle/caches
|
|
||||||
~/.gradle/wrapper
|
|
||||||
~/.kotlin
|
|
||||||
key: gradle-${{ runner.os }}-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/*.gradle.kts') }}
|
|
||||||
restore-keys: |
|
|
||||||
gradle-${{ runner.os }}-
|
|
||||||
|
|
||||||
- name: Make gradlew executable
|
|
||||||
run: chmod +x ./gradlew
|
|
||||||
|
|
||||||
- name: Gradle wrapper validation
|
|
||||||
run: ./gradlew --version
|
|
||||||
|
|
||||||
- name: ktlint
|
|
||||||
run: ./gradlew ktlintCheck
|
|
||||||
|
|
||||||
- name: detekt
|
|
||||||
run: ./gradlew detekt
|
|
||||||
|
|
||||||
- name: Unit tests
|
|
||||||
run: ./gradlew testDebugUnitTest
|
|
||||||
|
|
||||||
- name: Assemble debug
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
run: ./gradlew assembleDebug
|
|
||||||
|
|
||||||
- name: Upload debug APK
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
# Stock action: it works on this forge since the runner moved to
|
|
||||||
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
|
|
||||||
# out of the action bundle (Scribe snippet #2271). Never @v3 — it reports
|
|
||||||
# success while Gitea serves artifacts back only through the v4 API, and
|
|
||||||
# it is what left 72 unreachable artifacts on this repo (Scribe 2270).
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
with:
|
|
||||||
name: minstrel-android-debug-${{ github.sha }}
|
|
||||||
path: android/app/build/outputs/apk/debug/app-debug.apk
|
|
||||||
if-no-files-found: error
|
|
||||||
+385
-18
@@ -72,9 +72,8 @@ name: release
|
|||||||
# Android APK and uploads it as a workflow artifact. The image-release
|
# Android APK and uploads it as a workflow artifact. The image-release
|
||||||
# job declares `needs: android-release`, so the docker image cannot
|
# job declares `needs: android-release`, so the docker image cannot
|
||||||
# start building until the APK is guaranteed-ready — no polling, no
|
# start building until the APK is guaranteed-ready — no polling, no
|
||||||
# race, no silent-failure mode. Asset attachment to the gitea Release
|
# race, no silent-failure mode. Attaching the APK to the gitea Release is
|
||||||
# happens in the same android-release job, so the Release-page download
|
# its own job (release-assets), behind the test gate below.
|
||||||
# link and the in-image bundled APK are both populated atomically.
|
|
||||||
#
|
#
|
||||||
# :latest always carries an APK. Because every main push also moves
|
# :latest always carries an APK. Because every main push also moves
|
||||||
# :latest (not just tags), a main build with no APK would silently strip
|
# :latest (not just tags), a main build with no APK would silently strip
|
||||||
@@ -84,8 +83,33 @@ name: release
|
|||||||
# recomputed ones — so no rebuild is needed, just a rebundle. Tag builds
|
# recomputed ones — so no rebuild is needed, just a rebundle. Tag builds
|
||||||
# keep bundling their own freshly-built APK.
|
# keep bundling their own freshly-built APK.
|
||||||
#
|
#
|
||||||
# Android testing (lint + detekt + unit tests, debug APK upload on main)
|
# THE GATE (rule 177, M462 #4984). Every verifying lane lives in this file —
|
||||||
# lives in android.yml and runs independently on every push.
|
# Go (vet, lint, short tests), the Postgres integration suite, the web app
|
||||||
|
# (npm audit, svelte-check, vitest), Android (ktlint, detekt, unit tests) and
|
||||||
|
# govulncheck — and every job that publishes something names each of them in
|
||||||
|
# `needs:` and requires `success` from each, by name. Nothing publishes on red.
|
||||||
|
#
|
||||||
|
# They used to be three separate workflows (test-go, test-web, android) on the
|
||||||
|
# same push trigger as this one. Separate workflows cannot see each other's
|
||||||
|
# verdict, so :dev meant "it built", never "it passed": a red test run and a
|
||||||
|
# fresh :dev could carry the same timestamp. One graph is the only place the
|
||||||
|
# edge can be written.
|
||||||
|
#
|
||||||
|
# A skipped lane is NOT a pass. The publishing conditions check
|
||||||
|
# `result == 'success'` per lane rather than `!failure()`, so a lane that
|
||||||
|
# never started blocks the publish exactly as a red one does. Lanes carry no
|
||||||
|
# path filters for the same reason: a web-only push still runs the Go suite,
|
||||||
|
# because "not run" must never read as "passed".
|
||||||
|
#
|
||||||
|
# What publishes, and is therefore gated: the image tags (image-release) and
|
||||||
|
# the APK + version sidecar attached to a tag's Release (release-assets).
|
||||||
|
# android-release only BUILDS the signed APK into a workflow artifact, which
|
||||||
|
# nobody outside this run can pull, so it runs in parallel with the lanes
|
||||||
|
# instead of after them; attaching it to the Release is the publishing half,
|
||||||
|
# and that half waits for the gate.
|
||||||
|
#
|
||||||
|
# To watch the gate refuse: dispatch this workflow with force_red=true. The go
|
||||||
|
# lane fails on purpose, and both publishing jobs must report skipped.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -95,6 +119,11 @@ on:
|
|||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- '**/*.md'
|
- '**/*.md'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
force_red:
|
||||||
|
description: Fail the go lane on purpose, to check that nothing publishes on red
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
# A rapid re-push to main should supersede the in-flight build — the
|
# A rapid re-push to main should supersede the in-flight build — the
|
||||||
# operator explicitly wants the later commit to win. Tags no longer enter
|
# operator explicitly wants the later commit to win. Tags no longer enter
|
||||||
@@ -105,6 +134,269 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# ---------------------------------------------------------------- lanes --
|
||||||
|
# Verifying jobs. Each one is named in the `needs:` of every publishing job
|
||||||
|
# below; add a lane here and it must be added there in the same commit.
|
||||||
|
|
||||||
|
go:
|
||||||
|
runs-on: go-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Forced failure (gate check)
|
||||||
|
if: github.event.inputs.force_red == 'true'
|
||||||
|
run: |
|
||||||
|
echo "::error::force_red dispatch: failing on purpose so the publishing jobs must skip"
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Toolchain versions
|
||||||
|
run: |
|
||||||
|
go version
|
||||||
|
golangci-lint --version
|
||||||
|
|
||||||
|
- name: Generated code matches queries (sqlc)
|
||||||
|
run: make verify-generate
|
||||||
|
|
||||||
|
- name: go vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: golangci-lint
|
||||||
|
run: golangci-lint run ./...
|
||||||
|
|
||||||
|
- name: go test (short, race)
|
||||||
|
run: go test -short -race ./...
|
||||||
|
|
||||||
|
# Full `go test -race` against an ephemeral Postgres.
|
||||||
|
#
|
||||||
|
# DB wiring follows the act_runner shared-daemon pattern: the runner's Docker
|
||||||
|
# daemon also runs the operator's dev compose stack, so service containers
|
||||||
|
# get NO published ports (collision) and no service-name DNS. We discover the
|
||||||
|
# service container through the mounted docker socket and reach it by bridge
|
||||||
|
# IP. The exactly-one assertion is a hard guard — pointing tests at the dev
|
||||||
|
# Postgres would truncate it (the disaster Fable #339 exists to prevent).
|
||||||
|
#
|
||||||
|
# The key stays `integration` with no `name:` (rule 80): act_runner derives
|
||||||
|
# the service container's name from the job's display name.
|
||||||
|
#
|
||||||
|
# `web/build/` has a committed placeholder index.html so go:embed succeeds
|
||||||
|
# without the SPA being built first.
|
||||||
|
integration:
|
||||||
|
runs-on: go-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: minstrel
|
||||||
|
POSTGRES_PASSWORD: minstrel
|
||||||
|
POSTGRES_DB: minstrel_test
|
||||||
|
# No `ports:` — the runner shares the operator's dev compose
|
||||||
|
# Docker daemon; publishing a fixed host port collides.
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Integration suite (discover service by bridge IP, migrate, test)
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
# Discover THIS job's Postgres service container via the
|
||||||
|
# mounted docker socket. act_runner attaches the job
|
||||||
|
# container and its service container(s) to a shared per-job
|
||||||
|
# network, so scope discovery to a postgres that sits on a
|
||||||
|
# network THIS job container is also on. The old
|
||||||
|
# `--filter name=integration` matched EVERY concurrent
|
||||||
|
# integration run's postgres (a dev push + the main-merge run
|
||||||
|
# overlap → 2 candidates → false "expected exactly 1" abort).
|
||||||
|
# The operator's dev compose `minstrel-postgres-*` is never on
|
||||||
|
# this job's network; skip it explicitly as belt-and-suspenders
|
||||||
|
# (a wrong target would truncate real data).
|
||||||
|
SELF=$(cat /etc/hostname)
|
||||||
|
SELF_NETS=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$SELF")
|
||||||
|
test -n "$SELF_NETS"
|
||||||
|
echo "self ($SELF) networks: $SELF_NETS"
|
||||||
|
PG_ID=""
|
||||||
|
PG_NAME=""
|
||||||
|
for cid in $(docker ps --filter "ancestor=postgres:16-alpine" -q); do
|
||||||
|
nm=$(docker inspect -f '{{.Name}}' "$cid" | sed 's#^/##')
|
||||||
|
case "$nm" in *minstrel-postgres*|*_postgres_*) continue ;; esac
|
||||||
|
for net in $(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$cid"); do
|
||||||
|
case " $SELF_NETS " in *" $net "*) PG_ID="$cid"; PG_NAME="$nm"; break 2 ;; esac
|
||||||
|
done
|
||||||
|
done
|
||||||
|
test -n "$PG_ID" || { echo "FATAL: no postgres service container on this job's network (self nets: $SELF_NETS)"; exit 1; }
|
||||||
|
echo "selected postgres: $PG_ID $PG_NAME"
|
||||||
|
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG_ID")
|
||||||
|
test -n "$PG_IP"
|
||||||
|
export MINSTREL_TEST_DATABASE_URL="postgres://minstrel:minstrel@${PG_IP}:5432/minstrel_test?sslmode=disable"
|
||||||
|
|
||||||
|
# Wait for Postgres to accept connections. Asked of the service
|
||||||
|
# container itself: the run: shell is dash (rule 81), where the
|
||||||
|
# old `/dev/tcp` probe never connects and the loop silently
|
||||||
|
# burned its full two minutes on every run.
|
||||||
|
ready=""
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if docker exec "$PG_ID" pg_isready -U minstrel -d minstrel_test -q; then ready=1; break; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
test -n "$ready" || { echo "FATAL: postgres never became ready"; exit 1; }
|
||||||
|
|
||||||
|
# Relax durability on the throwaway CI Postgres. Our test pattern
|
||||||
|
# is dbtest.ResetDB → TRUNCATE … RESTART IDENTITY CASCADE before
|
||||||
|
# every test, and the per-TRUNCATE commit fsync is the dominant
|
||||||
|
# cost of the integration suite. The CI DB is rebuilt every run so
|
||||||
|
# fsync / full_page_writes / synchronous_commit buy nothing. Apply
|
||||||
|
# via docker exec because:
|
||||||
|
# - The act_runner `services:` block can't override the container
|
||||||
|
# command, so `postgres -c fsync=off` at boot isn't an option.
|
||||||
|
# - ALTER SYSTEM cannot run inside a transaction; psql -c
|
||||||
|
# auto-commits each statement, which is what we need.
|
||||||
|
# - fsync / full_page_writes are sighup GUCs and
|
||||||
|
# synchronous_commit is user-context, so pg_reload_conf() picks
|
||||||
|
# all three up with no restart.
|
||||||
|
# Non-fatal: a perms surprise degrades to "slower", never red CI.
|
||||||
|
docker exec "$PG_ID" psql -U minstrel -d minstrel_test \
|
||||||
|
-c "ALTER SYSTEM SET fsync = off" \
|
||||||
|
-c "ALTER SYSTEM SET synchronous_commit = off" \
|
||||||
|
-c "ALTER SYSTEM SET full_page_writes = off" \
|
||||||
|
-c "SELECT pg_reload_conf()" \
|
||||||
|
|| echo "WARN: durability relax failed; continuing"
|
||||||
|
|
||||||
|
# Apply embedded migrations to the fresh test DB, then run the
|
||||||
|
# full suite (no -short → integration tests execute). -p 1:
|
||||||
|
# every integration package TRUNCATEs the one shared test DB;
|
||||||
|
# concurrent package binaries → TRUNCATE deadlocks. Serialize
|
||||||
|
# package execution (the documented local invocation too).
|
||||||
|
# -timeout 20m: internal/api alone takes ~6.5 min under -race on
|
||||||
|
# an idle runner, and a dev and a main run sharing the runner
|
||||||
|
# pushed it past go test's default 10m (run 8368, 600.016s, the
|
||||||
|
# running test 2s old — nothing hung).
|
||||||
|
MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate
|
||||||
|
go test -p 1 -race -timeout 20m ./...
|
||||||
|
|
||||||
|
web:
|
||||||
|
runs-on: go-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: web
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
# What ships to browsers: `dependencies` and the runtime they pull in
|
||||||
|
# (svelte, devalue). Build and test tooling (vite, vitest, tailwind,
|
||||||
|
# kit's dev server) is left out because none of it reaches a user, and
|
||||||
|
# its open advisories need major-version upgrades tracked separately.
|
||||||
|
- name: npm audit (shipped dependencies)
|
||||||
|
run: npm audit --omit=dev --audit-level=moderate
|
||||||
|
|
||||||
|
- name: Type-check + svelte-check
|
||||||
|
run: npm run check
|
||||||
|
|
||||||
|
- name: Vitest
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
android:
|
||||||
|
# Using flutter-ci runner label because it's the only proven-working
|
||||||
|
# label with docker that can pull our container.image.
|
||||||
|
runs-on: flutter-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-android:36
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: android
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Silences the JDK 22+ "restricted method in java.lang.System has been
|
||||||
|
# called" warning that Gradle's bundled native-platform jar trips at
|
||||||
|
# launch. Affects the LAUNCHER JVM, not the daemon — that's why
|
||||||
|
# org.gradle.jvmargs in gradle.properties isn't enough.
|
||||||
|
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Cache Gradle dirs
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
~/.kotlin
|
||||||
|
key: gradle-${{ runner.os }}-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/*.gradle.kts') }}
|
||||||
|
restore-keys: |
|
||||||
|
gradle-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Make gradlew executable
|
||||||
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
|
- name: Gradle wrapper validation
|
||||||
|
run: ./gradlew --version
|
||||||
|
|
||||||
|
- name: ktlint
|
||||||
|
run: ./gradlew ktlintCheck
|
||||||
|
|
||||||
|
- name: detekt
|
||||||
|
run: ./gradlew detekt
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: ./gradlew testDebugUnitTest
|
||||||
|
|
||||||
|
# No debug APK is built or uploaded here. Main used to upload a
|
||||||
|
# debug-signed app-debug.apk: a build signed by a key regenerated in
|
||||||
|
# every container, which no install can update (family idea #5103,
|
||||||
|
# practice 2). Phones get builds from android-release, signed with
|
||||||
|
# the one release key, on dev and on tags.
|
||||||
|
|
||||||
|
# Known vulnerabilities in the Go code and the standard library it is built
|
||||||
|
# with. Runs in the SAME image the Dockerfile's builder stage uses, so the
|
||||||
|
# standard library it checks is the one that ends up in the shipped binary;
|
||||||
|
# the ci-go image carries its own Go and would be checking a different
|
||||||
|
# toolchain. Keep this image and the Dockerfile's builder in step.
|
||||||
|
#
|
||||||
|
# govulncheck is fetched at CI time, unpinned (rule 154): the vulnerability
|
||||||
|
# database and the tool that reads it should both be current.
|
||||||
|
govulncheck:
|
||||||
|
runs-on: go-ci
|
||||||
|
container:
|
||||||
|
image: golang:1.26-bookworm
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Plain git, not actions/checkout: that action runs on node, which the
|
||||||
|
# golang image does not carry. This step is dash (rule 81).
|
||||||
|
- name: Checkout
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
auth=$(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)
|
||||||
|
git init -q .
|
||||||
|
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
|
||||||
|
git -c http.extraHeader="Authorization: Basic ${auth}" fetch -q --depth 1 origin "${{ github.sha }}"
|
||||||
|
git checkout -q FETCH_HEAD
|
||||||
|
git log -1 --format='%H %s'
|
||||||
|
|
||||||
|
- name: govulncheck
|
||||||
|
run: |
|
||||||
|
go version
|
||||||
|
go run golang.org/x/vuln/cmd/govulncheck@latest ./...
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ artifacts --
|
||||||
|
|
||||||
android-release:
|
android-release:
|
||||||
name: Build signed APK (releases and dev)
|
name: Build signed APK (releases and dev)
|
||||||
# Also builds on `dev`, which is what makes a test channel possible at
|
# Also builds on `dev`, which is what makes a test channel possible at
|
||||||
@@ -231,6 +523,42 @@ jobs:
|
|||||||
-PMINSTREL_VERSION_NAME=${{ steps.ver.outputs.name }} \
|
-PMINSTREL_VERSION_NAME=${{ steps.ver.outputs.name }} \
|
||||||
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
|
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
|
||||||
|
|
||||||
|
# The APK every phone updates from must carry THE release key: Android
|
||||||
|
# updates an app in place only when the signer matches, so an APK
|
||||||
|
# signed by any other key (debug, a regenerated keystore, a swapped
|
||||||
|
# secret) reaches no installed phone. The certificate's digest is
|
||||||
|
# pinned below; it is public, not a secret. Gradle signs with the
|
||||||
|
# release key or leaves the APK unsigned, and an unsigned build fails
|
||||||
|
# here too, as there is no app-release.apk to verify (family idea
|
||||||
|
# #5103, practice 3). apksigner, not keytool: keytool prints nothing
|
||||||
|
# for a v2-only APK.
|
||||||
|
#
|
||||||
|
# Rotating the key on purpose means every install must be removed and
|
||||||
|
# reinstalled; change the digest here in the same commit.
|
||||||
|
- name: The APK carries the release key
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
# CN=Minstrel, O=FabledSword. Read from run 8446 (#5116).
|
||||||
|
RELEASE_CERT_SHA256: 43d183307bc46b821789d90444a960b137f78f2166ff431efb2406d0fceaf612
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
|
||||||
|
signer="$(ls "$sdk"/build-tools/*/apksigner 2>/dev/null | sort -V | tail -1 || true)"
|
||||||
|
test -n "$signer" || { echo "::error::no apksigner under '$sdk/build-tools'"; exit 1; }
|
||||||
|
certs="$("$signer" verify --print-certs app/build/outputs/apk/release/app-release.apk)"
|
||||||
|
printf '%s\n' "$certs" | grep -E '^Signer #[0-9]+ certificate (DN|SHA-256 digest)'
|
||||||
|
# One signer, and it is ours. A second signer would be a lineage or
|
||||||
|
# a mistake; either way not something to ship unexamined.
|
||||||
|
digests="$(printf '%s\n' "$certs" | sed -n 's/^Signer #[0-9]* certificate SHA-256 digest: //p')"
|
||||||
|
if [ "$digests" != "$RELEASE_CERT_SHA256" ]; then
|
||||||
|
if printf '%s' "$certs" | grep -q 'CN=Android Debug'; then
|
||||||
|
echo "::error::the release APK is signed with a debug key"
|
||||||
|
else
|
||||||
|
echo "::error::the release APK is not signed by the release key: got '${digests//$'\n'/ }', want ${RELEASE_CERT_SHA256}"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Upload APK as workflow artifact
|
- name: Upload APK as workflow artifact
|
||||||
# Stock action (snippet #2271) — never @v3, which uploads something Gitea
|
# Stock action (snippet #2271) — never @v3, which uploads something Gitea
|
||||||
# will never serve back. This is the producing half of a pair:
|
# will never serve back. This is the producing half of a pair:
|
||||||
@@ -245,21 +573,47 @@ jobs:
|
|||||||
# artifact existing, so an empty upload must fail here, not there.
|
# artifact existing, so an empty upload must fail here, not there.
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
|
# Publishes the signed APK and its version sidecar on the tag's Release.
|
||||||
|
# Split out of android-release so the APK can be BUILT in parallel with the
|
||||||
|
# lanes while being PUBLISHED only once they have all passed.
|
||||||
|
release-assets:
|
||||||
|
name: Attach APK to the Release (tag releases only)
|
||||||
|
needs: [go, integration, web, android, govulncheck, android-release]
|
||||||
|
if: >-
|
||||||
|
${{
|
||||||
|
!cancelled()
|
||||||
|
&& needs.go.result == 'success'
|
||||||
|
&& needs.integration.result == 'success'
|
||||||
|
&& needs.web.result == 'success'
|
||||||
|
&& needs.android.result == 'success'
|
||||||
|
&& needs.govulncheck.result == 'success'
|
||||||
|
&& needs.android-release.result == 'success'
|
||||||
|
&& startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
runs-on: go-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download signed APK artifact
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: minstrel-apk
|
||||||
|
path: release-apk/
|
||||||
|
|
||||||
- name: Attach APK to gitea Release
|
- name: Attach APK to gitea Release
|
||||||
# Tag releases only. A dev build has no Release to hang assets on and
|
# Tag releases only. A dev build has no Release to hang assets on and
|
||||||
# does not need one — the :dev image bundles the APK, and the server
|
# does not need one — the :dev image bundles the APK, and the server
|
||||||
# serves it from /api/client/apk like any other.
|
# serves it from /api/client/apk like any other.
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||||
VERSION_NAME: ${{ steps.ver.outputs.name }}
|
VERSION_NAME: ${{ needs.android-release.outputs.version_name }}
|
||||||
VERSION_CODE: ${{ steps.ver.outputs.code }}
|
VERSION_CODE: ${{ needs.android-release.outputs.version_code }}
|
||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
TAG="${GITHUB_REF#refs/tags/}"
|
TAG="${GITHUB_REF#refs/tags/}"
|
||||||
REPO="${GITHUB_REPOSITORY}"
|
REPO="${GITHUB_REPOSITORY}"
|
||||||
APK_PATH="app/build/outputs/apk/release/app-release.apk"
|
APK_PATH="release-apk/app-release.apk"
|
||||||
ls -lh "${APK_PATH}"
|
ls -lh "${APK_PATH}"
|
||||||
|
|
||||||
# Publish the version sidecar as a release asset next to the APK.
|
# Publish the version sidecar as a release asset next to the APK.
|
||||||
@@ -313,13 +667,21 @@ jobs:
|
|||||||
|
|
||||||
image-release:
|
image-release:
|
||||||
name: Build + push container image
|
name: Build + push container image
|
||||||
# `needs:` waits for android-release. For tag pushes android-release
|
# Every lane must have SUCCEEDED, each named here (rule 177). Then the
|
||||||
# runs and must succeed before this job starts — guaranteeing the
|
# APK: tag and dev pushes build one and it must have succeeded; main
|
||||||
# APK artifact is present. For main pushes android-release is
|
# pushes skip android-release and bundle the latest release's APK
|
||||||
# skipped; the `if: ...` below lets this job run anyway and the
|
# instead, so for main alone a skipped android-release is expected.
|
||||||
# download/copy steps gate themselves on the tag context.
|
needs: [go, integration, web, android, govulncheck, android-release]
|
||||||
needs: [android-release]
|
if: >-
|
||||||
if: ${{ !failure() && !cancelled() }}
|
${{
|
||||||
|
!cancelled()
|
||||||
|
&& needs.go.result == 'success'
|
||||||
|
&& needs.integration.result == 'success'
|
||||||
|
&& needs.web.result == 'success'
|
||||||
|
&& needs.android.result == 'success'
|
||||||
|
&& needs.govulncheck.result == 'success'
|
||||||
|
&& (needs.android-release.result == 'success'
|
||||||
|
|| (needs.android-release.result == 'skipped' && github.ref == 'refs/heads/main')) }}
|
||||||
runs-on: go-ci
|
runs-on: go-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||||
@@ -528,8 +890,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
if: steps.guard.outputs.ready == 'true'
|
if: steps.guard.outputs.ready == 'true'
|
||||||
|
# --pull: the Dockerfile's base images are floating tags (golang:1.26,
|
||||||
|
# debian:bookworm-slim). Without it the runner's daemon reuses
|
||||||
|
# whatever it cached, and the shipped binary can sit on a Go patch
|
||||||
|
# release govulncheck already flagged while the lane, which pulls
|
||||||
|
# fresh, reports clean.
|
||||||
run: |
|
run: |
|
||||||
docker buildx build \
|
docker buildx build --pull \
|
||||||
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
|
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
|
||||||
--build-arg MINSTREL_CHANNEL="${{ steps.tags.outputs.channel }}" \
|
--build-arg MINSTREL_CHANNEL="${{ steps.tags.outputs.channel }}" \
|
||||||
--push ${{ steps.tags.outputs.args }} .
|
--push ${{ steps.tags.outputs.args }} .
|
||||||
@@ -554,7 +921,7 @@ jobs:
|
|||||||
# above did NOT succeed.
|
# above did NOT succeed.
|
||||||
verify-release:
|
verify-release:
|
||||||
name: Verify release artifacts (tag releases only)
|
name: Verify release artifacts (tag releases only)
|
||||||
needs: [android-release, image-release]
|
needs: [android-release, release-assets, image-release]
|
||||||
if: ${{ always() && startsWith(github.ref, 'refs/tags/v') }}
|
if: ${{ always() && startsWith(github.ref, 'refs/tags/v') }}
|
||||||
runs-on: go-ci
|
runs-on: go-ci
|
||||||
container:
|
container:
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
name: test-go
|
|
||||||
|
|
||||||
# Go server: vet + golangci-lint + short race tests. Runs on push to
|
|
||||||
# dev/main and PRs to main, scoped to Go-side files only — web-only or
|
|
||||||
# Flutter-only diffs don't trigger this workflow.
|
|
||||||
#
|
|
||||||
# Two jobs: `test` (fast — vet + lint + `go test -short -race`, no DB) and
|
|
||||||
# `integration` (full `go test -race` against an ephemeral Postgres).
|
|
||||||
#
|
|
||||||
# Integration-job DB wiring follows the act_runner shared-daemon pattern:
|
|
||||||
# the runner's Docker daemon also runs the operator's dev compose stack,
|
|
||||||
# so service containers get NO published ports (collision) and no
|
|
||||||
# service-name DNS. We discover the service container by the job-scoped
|
|
||||||
# name filter via the mounted docker socket and reach it by bridge IP.
|
|
||||||
# The exactly-one assertion is a hard guard — pointing tests at the dev
|
|
||||||
# Postgres would truncate it (the disaster Fable #339 exists to prevent).
|
|
||||||
#
|
|
||||||
# `web/build/` has a committed placeholder index.html so go:embed succeeds
|
|
||||||
# without needing the SPA to be freshly built. Real builds happen in
|
|
||||||
# release.yml (container) and locally during dev.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [dev, main]
|
|
||||||
paths:
|
|
||||||
- '**/*.go'
|
|
||||||
- 'go.mod'
|
|
||||||
- 'go.sum'
|
|
||||||
- 'sqlc.yaml'
|
|
||||||
- 'Makefile'
|
|
||||||
- 'internal/**'
|
|
||||||
- 'cmd/**'
|
|
||||||
- '.golangci.yml'
|
|
||||||
- '.gitea/workflows/test-go.yml'
|
|
||||||
# The release lane's own trigger is `main` + tags, so nothing it
|
|
||||||
# contains is exercised until a release is already running. These two
|
|
||||||
# entries are what let internal/server/release_version_test.go guard
|
|
||||||
# the version derivation on ordinary dev pushes instead.
|
|
||||||
- 'ci/**'
|
|
||||||
- '.gitea/workflows/release.yml'
|
|
||||||
|
|
||||||
# pull_request trigger intentionally omitted — see test-web.yml for
|
|
||||||
# the rationale (single-author repo, push covers PR-merge equivalent).
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: go-ci
|
|
||||||
container:
|
|
||||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Toolchain versions
|
|
||||||
run: |
|
|
||||||
go version
|
|
||||||
golangci-lint --version
|
|
||||||
|
|
||||||
- name: Generated code matches queries (sqlc)
|
|
||||||
run: make verify-generate
|
|
||||||
|
|
||||||
- name: go vet
|
|
||||||
run: go vet ./...
|
|
||||||
|
|
||||||
- name: golangci-lint
|
|
||||||
run: golangci-lint run ./...
|
|
||||||
|
|
||||||
- name: go test (short, race)
|
|
||||||
run: go test -short -race ./...
|
|
||||||
|
|
||||||
integration:
|
|
||||||
runs-on: go-ci
|
|
||||||
container:
|
|
||||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
env:
|
|
||||||
POSTGRES_USER: minstrel
|
|
||||||
POSTGRES_PASSWORD: minstrel
|
|
||||||
POSTGRES_DB: minstrel_test
|
|
||||||
# No `ports:` — the runner shares the operator's dev compose
|
|
||||||
# Docker daemon; publishing a fixed host port collides.
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Integration suite (discover service by bridge IP, migrate, test)
|
|
||||||
run: |
|
|
||||||
set -eux
|
|
||||||
# Discover THIS job's Postgres service container via the
|
|
||||||
# mounted docker socket. act_runner attaches the job
|
|
||||||
# container and its service container(s) to a shared per-job
|
|
||||||
# network, so scope discovery to a postgres that sits on a
|
|
||||||
# network THIS job container is also on. The old
|
|
||||||
# `--filter name=integration` matched EVERY concurrent
|
|
||||||
# integration run's postgres (a dev push + the main-merge run
|
|
||||||
# overlap → 2 candidates → false "expected exactly 1" abort).
|
|
||||||
# The operator's dev compose `minstrel-postgres-*` is never on
|
|
||||||
# this job's network; skip it explicitly as belt-and-suspenders
|
|
||||||
# (a wrong target would truncate real data).
|
|
||||||
SELF=$(cat /etc/hostname)
|
|
||||||
SELF_NETS=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$SELF")
|
|
||||||
test -n "$SELF_NETS"
|
|
||||||
echo "self ($SELF) networks: $SELF_NETS"
|
|
||||||
PG_ID=""
|
|
||||||
PG_NAME=""
|
|
||||||
for cid in $(docker ps --filter "ancestor=postgres:16-alpine" -q); do
|
|
||||||
nm=$(docker inspect -f '{{.Name}}' "$cid" | sed 's#^/##')
|
|
||||||
case "$nm" in *minstrel-postgres*|*_postgres_*) continue ;; esac
|
|
||||||
for net in $(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$cid"); do
|
|
||||||
case " $SELF_NETS " in *" $net "*) PG_ID="$cid"; PG_NAME="$nm"; break 2 ;; esac
|
|
||||||
done
|
|
||||||
done
|
|
||||||
test -n "$PG_ID" || { echo "FATAL: no postgres service container on this job's network (self nets: $SELF_NETS)"; exit 1; }
|
|
||||||
echo "selected postgres: $PG_ID $PG_NAME"
|
|
||||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG_ID")
|
|
||||||
test -n "$PG_IP"
|
|
||||||
export MINSTREL_TEST_DATABASE_URL="postgres://minstrel:minstrel@${PG_IP}:5432/minstrel_test?sslmode=disable"
|
|
||||||
|
|
||||||
# Wait for Postgres to accept TCP (no health-check dependency).
|
|
||||||
for i in $(seq 1 60); do (echo > "/dev/tcp/${PG_IP}/5432") 2>/dev/null && break; sleep 2; done
|
|
||||||
|
|
||||||
# Relax durability on the throwaway CI Postgres. Our test pattern
|
|
||||||
# is dbtest.ResetDB → TRUNCATE … RESTART IDENTITY CASCADE before
|
|
||||||
# every test, and the per-TRUNCATE commit fsync is the dominant
|
|
||||||
# cost of the integration suite. The CI DB is rebuilt every run so
|
|
||||||
# fsync / full_page_writes / synchronous_commit buy nothing. Apply
|
|
||||||
# via docker exec because:
|
|
||||||
# - The act_runner `services:` block can't override the container
|
|
||||||
# command, so `postgres -c fsync=off` at boot isn't an option.
|
|
||||||
# - ALTER SYSTEM cannot run inside a transaction; psql -c
|
|
||||||
# auto-commits each statement, which is what we need.
|
|
||||||
# - fsync / full_page_writes are sighup GUCs and
|
|
||||||
# synchronous_commit is user-context, so pg_reload_conf() picks
|
|
||||||
# all three up with no restart.
|
|
||||||
# Non-fatal: a perms surprise degrades to "slower", never red CI.
|
|
||||||
docker exec "$PG_ID" psql -U minstrel -d minstrel_test \
|
|
||||||
-c "ALTER SYSTEM SET fsync = off" \
|
|
||||||
-c "ALTER SYSTEM SET synchronous_commit = off" \
|
|
||||||
-c "ALTER SYSTEM SET full_page_writes = off" \
|
|
||||||
-c "SELECT pg_reload_conf()" \
|
|
||||||
|| echo "WARN: durability relax failed; continuing"
|
|
||||||
|
|
||||||
# Apply embedded migrations to the fresh test DB, then run the
|
|
||||||
# full suite (no -short → integration tests execute). -p 1:
|
|
||||||
# every integration package TRUNCATEs the one shared test DB;
|
|
||||||
# concurrent package binaries → TRUNCATE deadlocks. Serialize
|
|
||||||
# package execution (the documented local invocation too).
|
|
||||||
MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate
|
|
||||||
go test -p 1 -race ./...
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
name: test-web
|
|
||||||
|
|
||||||
# Web SPA: vitest + svelte-check. Runs on push to dev/main only —
|
|
||||||
# the `pull_request` trigger is intentionally omitted because every
|
|
||||||
# branch on this repo is local-only (no fork PRs), so the dev push
|
|
||||||
# fully covers what a PR run would re-execute. Keeping both events
|
|
||||||
# doubled CI cost on every commit.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [dev, main]
|
|
||||||
paths:
|
|
||||||
- 'web/**'
|
|
||||||
- '.gitea/workflows/test-web.yml'
|
|
||||||
|
|
||||||
# Cancel an earlier in-flight run for the same ref when a newer
|
|
||||||
# commit arrives. With cancel-in-progress, rapid re-pushes don't
|
|
||||||
# pile up zombie runs.
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: go-ci
|
|
||||||
container:
|
|
||||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
|
||||||
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: web
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install deps
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Type-check + svelte-check
|
|
||||||
run: npm run check
|
|
||||||
|
|
||||||
- name: Vitest
|
|
||||||
run: npm test
|
|
||||||
+8
-2
@@ -7,7 +7,7 @@ RUN npm ci
|
|||||||
COPY web/ ./
|
COPY web/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM golang:1.25-bookworm AS builder
|
FROM golang:1.26-bookworm AS builder
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
@@ -33,8 +33,14 @@ RUN go build -trimpath \
|
|||||||
-o /out/minstrel ./cmd/minstrel
|
-o /out/minstrel ./cmd/minstrel
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
# ffmpeg: duration probes and the exact-tier audio hash (a SHA-256 of the
|
||||||
|
# encoded audio packets, so no decode). libchromaprint-tools: fpcalc, the
|
||||||
|
# acoustic fingerprint that tells the same recording at two bitrates apart
|
||||||
|
# from two different recordings (M400). Both are baked in at build time so a
|
||||||
|
# deployed instance never fetches either (rule 164); fpcalc is shelled out
|
||||||
|
# rather than bound because CGO_ENABLED=0 above rules out cgo.
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg \
|
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg libchromaprint-tools \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN groupadd --system --gid 1000 minstrel \
|
RUN groupadd --system --gid 1000 minstrel \
|
||||||
|
|||||||
@@ -34,11 +34,18 @@ Minstrel is not affiliated with or endorsed by Lidarr, ListenBrainz, MusicBrainz
|
|||||||
services:
|
services:
|
||||||
minstrel:
|
minstrel:
|
||||||
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
||||||
|
# Reachable from your LAN at http://<host>:4533. If this host faces the
|
||||||
|
# internet, bind it to 127.0.0.1 and put an HTTPS proxy in front instead:
|
||||||
|
# see docs/hosting.md.
|
||||||
ports: ['4533:4533']
|
ports: ['4533:4533']
|
||||||
volumes:
|
volumes:
|
||||||
# Your music library. Point ./music at wherever your audio files
|
# Your music library. Point ./music at wherever your audio files
|
||||||
# live. Mounted read-only — Minstrel never writes to your library.
|
# live. Writable, because Minstrel deletes a file when an admin asks
|
||||||
- ./music:/music:ro
|
# it to (for example, quarantine's "Delete file"). It never moves,
|
||||||
|
# renames or retags anything. The container runs as uid 1000, so that
|
||||||
|
# user needs write access to the folders. Mount it :ro to forbid even
|
||||||
|
# deletes: those actions then refuse, say why, and delete nothing.
|
||||||
|
- ./music:/music
|
||||||
# Generated data: playlist cover collages, artist art, caches.
|
# Generated data: playlist cover collages, artist art, caches.
|
||||||
# The path must match MINSTREL_STORAGE_DATA_DIR, which the image
|
# The path must match MINSTREL_STORAGE_DATA_DIR, which the image
|
||||||
# sets to /app/data — keep this mount on /app/data or your cache
|
# sets to /app/data — keep this mount on /app/data or your cache
|
||||||
@@ -47,7 +54,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
|
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
|
||||||
# Colon-separated library roots to scan; must match the container
|
# Colon-separated library roots to scan; must match the container
|
||||||
# path of the read-only music mount above (/music here).
|
# path of the music mount above (/music here).
|
||||||
MINSTREL_LIBRARY_SCAN_PATHS: /music
|
MINSTREL_LIBRARY_SCAN_PATHS: /music
|
||||||
depends_on: [db]
|
depends_on: [db]
|
||||||
|
|
||||||
@@ -72,9 +79,9 @@ docker compose up -d
|
|||||||
|
|
||||||
## First run
|
## First run
|
||||||
|
|
||||||
With the stack up, a handful of in-app steps get you to a working library. Use your own host in place of `localhost` if you're reaching the server over a LAN/VPN address (plain `http://` is fine — no TLS required).
|
With the stack up, a handful of in-app steps get you to a working library. Use your own host in place of `localhost` if you're reaching the server over a LAN/VPN address. Plain `http://` is fine on a network you trust; a server reachable from the internet belongs behind HTTPS, which [docs/hosting.md](docs/hosting.md) walks through.
|
||||||
|
|
||||||
**1. Create your admin account.** Visit `http://localhost:4533/register`. The first account on a fresh instance is automatically the administrator; later users join through the same form or an invite token (step 5).
|
**1. Create your admin account.** Visit `http://localhost:4533/register`. The first account on a fresh instance becomes the administrator, and creating it asks for the **setup token** the server prints in its log (`docker compose logs minstrel | grep setup_token`), so nobody else can claim a newly exposed server first. Later users join through the same form or an invite token (step 5).
|
||||||
|
|
||||||
<a href="docs/screenshots/register.png"><img src="docs/screenshots/register.png" width="320" alt="Creating the first (admin) account on a fresh instance"></a>
|
<a href="docs/screenshots/register.png"><img src="docs/screenshots/register.png" width="320" alt="Creating the first (admin) account on a fresh instance"></a>
|
||||||
|
|
||||||
@@ -96,6 +103,8 @@ With the stack up, a handful of in-app steps get you to a working library. Use y
|
|||||||
|
|
||||||
For the full configuration surface, see [`config.example.yaml`](./config.example.yaml).
|
For the full configuration surface, see [`config.example.yaml`](./config.example.yaml).
|
||||||
|
|
||||||
|
Hosting Minstrel on the internet: see [docs/hosting.md](docs/hosting.md). What Minstrel does to protect accounts, and why: [docs/security.md](docs/security.md).
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Most operators only need the env vars in the quickstart above. A few extras worth knowing:
|
Most operators only need the env vars in the quickstart above. A few extras worth knowing:
|
||||||
@@ -150,7 +159,8 @@ Two concurrent dev processes:
|
|||||||
truncates your dev `minstrel` data (admin user, library, likes). It
|
truncates your dev `minstrel` data (admin user, library, likes). It
|
||||||
brings up the compose Postgres and creates the test DB if missing.
|
brings up the compose Postgres and creates the test DB if missing.
|
||||||
- CI runs both: a fast `go test -short -race` gate plus an integration
|
- CI runs both: a fast `go test -short -race` gate plus an integration
|
||||||
job with its own ephemeral Postgres (`.gitea/workflows/test-go.yml`).
|
job with its own ephemeral Postgres (the `integration` lane in
|
||||||
|
`.gitea/workflows/release.yml`, which also gates every image publish).
|
||||||
|
|
||||||
### Production build
|
### Production build
|
||||||
|
|
||||||
|
|||||||
@@ -72,9 +72,13 @@ android {
|
|||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro",
|
"proguard-rules.pro",
|
||||||
)
|
)
|
||||||
|
// Signed with the release key or not at all. Falling back to the
|
||||||
|
// debug key made a missing secret into a published APK that no
|
||||||
|
// install could ever update (family idea #5103, practice 2). An
|
||||||
|
// unsigned build installs nowhere, so the gap shows at once.
|
||||||
signingConfig =
|
signingConfig =
|
||||||
if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) {
|
if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) {
|
||||||
signingConfigs.getByName("debug")
|
null
|
||||||
} else {
|
} else {
|
||||||
signingConfigs.getByName("release")
|
signingConfigs.getByName("release")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,14 @@ import androidx.compose.material3.HorizontalDivider
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -42,6 +46,12 @@ fun AdminQuarantineScreen(
|
|||||||
viewModel: AdminQuarantineViewModel = hiltViewModel(),
|
viewModel: AdminQuarantineViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.transientMessages.collect { msg ->
|
||||||
|
snackbarHostState.showSnackbar(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
Scaffold(
|
Scaffold(
|
||||||
contentWindowInsets = ShellContentWindowInsets,
|
contentWindowInsets = ShellContentWindowInsets,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -53,6 +63,7 @@ fun AdminQuarantineScreen(
|
|||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { inner ->
|
) { inner ->
|
||||||
PullToRefreshScaffold(
|
PullToRefreshScaffold(
|
||||||
onRefresh = { viewModel.refresh().join() },
|
onRefresh = { viewModel.refresh().join() },
|
||||||
|
|||||||
+14
-1
@@ -10,10 +10,13 @@ import com.fabledsword.minstrel.events.EventsStream
|
|||||||
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -34,6 +37,15 @@ class AdminQuarantineViewModel @Inject constructor(
|
|||||||
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
|
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
|
||||||
val uiState: StateFlow<AdminQuarantineUiState> = internal.asStateFlow()
|
val uiState: StateFlow<AdminQuarantineUiState> = internal.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot messages for the screen's snackbar. A failed action has to say
|
||||||
|
* why: the row quietly reappearing reads as a glitch, and for a Delete
|
||||||
|
* file refused by a read-only library it hides the one thing the
|
||||||
|
* operator can fix (#3918).
|
||||||
|
*/
|
||||||
|
private val transientMessagesChannel = Channel<String>(Channel.BUFFERED)
|
||||||
|
val transientMessages: Flow<String> = transientMessagesChannel.receiveAsFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -86,8 +98,9 @@ class AdminQuarantineViewModel @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
action(trackId)
|
action(trackId)
|
||||||
} catch (
|
} catch (
|
||||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
) {
|
) {
|
||||||
|
transientMessagesChannel.trySend(ErrorCopy.fromThrowable(e))
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,14 @@ class BaseUrlInterceptor @Inject constructor(
|
|||||||
.port(baseUrl.port)
|
.port(baseUrl.port)
|
||||||
.build()
|
.build()
|
||||||
} ?: original.url
|
} ?: original.url
|
||||||
return chain.proceed(original.newBuilder().url(rewritten).build())
|
return chain.proceed(
|
||||||
|
original.newBuilder()
|
||||||
|
.url(rewritten)
|
||||||
|
// Lets CleartextGuardInterceptor tell server requests from
|
||||||
|
// external fetches once the placeholder host is gone.
|
||||||
|
.tag(MinstrelServerRequest::class.java, MinstrelServerRequest)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.fabledsword.minstrel.api
|
||||||
|
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.Inet6Address
|
||||||
|
import java.net.InetAddress
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a request as bound for the Minstrel server, set by
|
||||||
|
* [BaseUrlInterceptor] when it retargets the placeholder host. Those are the
|
||||||
|
* requests that carry the session cookie and the password.
|
||||||
|
*/
|
||||||
|
object MinstrelServerRequest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain `http://` to the Minstrel server is allowed only when the connection
|
||||||
|
* actually lands on a private address (family security baseline #5105,
|
||||||
|
* practice 13).
|
||||||
|
*
|
||||||
|
* Cleartext stays permitted app-wide for LAN servers and UPnP
|
||||||
|
* (network_security_config.xml, #2439), but a password or session cookie sent
|
||||||
|
* over plain HTTP to a public address can be read by anyone on the path.
|
||||||
|
*
|
||||||
|
* Checked per connection, on the address the socket really reached, not on
|
||||||
|
* the URL when it was typed: a name that resolved to the home network when it
|
||||||
|
* was entered resolves to a public address once the phone leaves home, and
|
||||||
|
* that is exactly when the password would go out in the clear. A network
|
||||||
|
* interceptor runs after the connection is made and before any request byte
|
||||||
|
* is written, so nothing is sent.
|
||||||
|
*/
|
||||||
|
class CleartextGuardInterceptor(
|
||||||
|
// The policy is a parameter so a test can refuse loopback, the only
|
||||||
|
// address a test server can listen on.
|
||||||
|
private val allows: (InetAddress) -> Boolean = CleartextPolicy::allows,
|
||||||
|
) : Interceptor {
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
if (request.isHttps || request.tag(MinstrelServerRequest::class.java) == null) {
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
val address = chain.connection()?.route()?.socketAddress?.address
|
||||||
|
if (address != null && !allows(address)) {
|
||||||
|
throw CleartextToPublicHostException(request.url.host)
|
||||||
|
}
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The server was reached over plain HTTP at a public address, and refused. */
|
||||||
|
class CleartextToPublicHostException(host: String) :
|
||||||
|
IOException("refusing plain http:// to $host: it is a public address")
|
||||||
|
|
||||||
|
/** Which addresses plain HTTP may reach: the home network, never the internet. */
|
||||||
|
object CleartextPolicy {
|
||||||
|
private const val CGNAT_FIRST_OCTET = 100
|
||||||
|
private const val CGNAT_SECOND_MASK = 0xC0
|
||||||
|
private const val CGNAT_SECOND_PREFIX = 64
|
||||||
|
private const val ULA_MASK = 0xFE
|
||||||
|
private const val ULA_PREFIX = 0xFC
|
||||||
|
private const val BYTE = 0xFF
|
||||||
|
|
||||||
|
fun allows(address: InetAddress): Boolean =
|
||||||
|
address.isLoopbackAddress ||
|
||||||
|
address.isSiteLocalAddress || // 10/8, 172.16/12, 192.168/16
|
||||||
|
address.isLinkLocalAddress || // 169.254/16, fe80::/10
|
||||||
|
address.isAnyLocalAddress ||
|
||||||
|
isCarrierGradeNat(address) ||
|
||||||
|
isUniqueLocal(address)
|
||||||
|
|
||||||
|
// 100.64/10. Tailscale and other overlay VPNs hand these out; the overlay
|
||||||
|
// encrypts the traffic itself.
|
||||||
|
private fun isCarrierGradeNat(address: InetAddress): Boolean {
|
||||||
|
if (address !is Inet4Address) return false
|
||||||
|
val b = address.address
|
||||||
|
return (b[0].toInt() and BYTE) == CGNAT_FIRST_OCTET &&
|
||||||
|
(b[1].toInt() and CGNAT_SECOND_MASK) == CGNAT_SECOND_PREFIX
|
||||||
|
}
|
||||||
|
|
||||||
|
// fc00::/7, IPv6's private range.
|
||||||
|
private fun isUniqueLocal(address: InetAddress): Boolean =
|
||||||
|
address is Inet6Address && (address.address[0].toInt() and ULA_MASK) == ULA_PREFIX
|
||||||
|
}
|
||||||
@@ -37,18 +37,36 @@ object ErrorCopy {
|
|||||||
* as connection failures.
|
* as connection failures.
|
||||||
*/
|
*/
|
||||||
fun fromThrowable(t: Throwable): String = when (t) {
|
fun fromThrowable(t: Throwable): String = when (t) {
|
||||||
is HttpException -> messageFor(codeFromHttp(t))
|
is HttpException -> fromHttp(t)
|
||||||
|
is CleartextToPublicHostException -> messageFor("cleartext_public")
|
||||||
is IOException -> messageFor("connection_refused")
|
is IOException -> messageFor("connection_refused")
|
||||||
else -> TABLE.getValue("unknown")
|
else -> TABLE.getValue("unknown")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun codeFromHttp(e: HttpException): String {
|
/**
|
||||||
|
* Codes whose server message carries specifics the operator needs in
|
||||||
|
* order to act — which directory, which uid — that fixed copy cannot say.
|
||||||
|
* For these the message follows the copy (#3918). Kept to a named set on
|
||||||
|
* purpose: most server messages are internal detail. Mirrors web's
|
||||||
|
* errors.ts.
|
||||||
|
*/
|
||||||
|
private val DETAIL_CODES = setOf("library_not_writable", "file_delete_failed")
|
||||||
|
|
||||||
|
private fun fromHttp(e: HttpException): String {
|
||||||
|
val body = bodyFromHttp(e)
|
||||||
|
val copy = messageFor(body.code.ifEmpty { "unknown" })
|
||||||
|
return if (body.code in DETAIL_CODES && body.message.isNotBlank()) {
|
||||||
|
"$copy ${body.message}"
|
||||||
|
} else {
|
||||||
|
copy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bodyFromHttp(e: HttpException): Body {
|
||||||
val raw = runCatching { e.response()?.errorBody()?.string() }.getOrNull()
|
val raw = runCatching { e.response()?.errorBody()?.string() }.getOrNull()
|
||||||
?: return "unknown"
|
?: return Body()
|
||||||
val code = runCatching { json.decodeFromString<Envelope>(raw).error?.code }
|
return runCatching { json.decodeFromString<Envelope>(raw).error }
|
||||||
.getOrNull()
|
.getOrNull() ?: Body()
|
||||||
.orEmpty()
|
|
||||||
return code.ifEmpty { "unknown" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val TABLE: Map<String, String> = mapOf(
|
private val TABLE: Map<String, String> = mapOf(
|
||||||
@@ -58,6 +76,7 @@ object ErrorCopy {
|
|||||||
"forbidden" to "You don't have permission to do that.",
|
"forbidden" to "You don't have permission to do that.",
|
||||||
"not_authorized" to "You don't have permission to do that.",
|
"not_authorized" to "You don't have permission to do that.",
|
||||||
"invalid_credentials" to "Wrong username or password.",
|
"invalid_credentials" to "Wrong username or password.",
|
||||||
|
"rate_limited" to "Too many attempts. Wait a few minutes and try again.",
|
||||||
"wrong_password" to "Current password is incorrect.",
|
"wrong_password" to "Current password is incorrect.",
|
||||||
"password_too_short" to "Password must be at least 8 characters.",
|
"password_too_short" to "Password must be at least 8 characters.",
|
||||||
"username_invalid" to "That username isn't valid.",
|
"username_invalid" to "That username isn't valid.",
|
||||||
@@ -83,6 +102,9 @@ object ErrorCopy {
|
|||||||
"mbid_required" to "An MBID is required for this lookup.",
|
"mbid_required" to "An MBID is required for this lookup.",
|
||||||
"system_playlist_readonly" to "System playlists can't be edited directly.",
|
"system_playlist_readonly" to "System playlists can't be edited directly.",
|
||||||
"connection_refused" to "Couldn't reach the server. Check the URL and try again.",
|
"connection_refused" to "Couldn't reach the server. Check the URL and try again.",
|
||||||
|
"cleartext_public" to
|
||||||
|
"This server is on the internet, so its URL must start with https://. " +
|
||||||
|
"Plain http:// only works on your home network.",
|
||||||
"lidarr_unreachable" to
|
"lidarr_unreachable" to
|
||||||
"Lidarr is unreachable right now. Try again, or check Admin → Integrations.",
|
"Lidarr is unreachable right now. Try again, or check Admin → Integrations.",
|
||||||
"lidarr_disabled" to "Lidarr integration is not enabled.",
|
"lidarr_disabled" to "Lidarr integration is not enabled.",
|
||||||
@@ -99,6 +121,8 @@ object ErrorCopy {
|
|||||||
"request_not_pending" to "This request is no longer pending.",
|
"request_not_pending" to "This request is no longer pending.",
|
||||||
"request_not_found" to "That request no longer exists.",
|
"request_not_found" to "That request no longer exists.",
|
||||||
"track_not_found" to "That track no longer exists.",
|
"track_not_found" to "That track no longer exists.",
|
||||||
|
"library_not_writable" to "The music library isn't writable by the server.",
|
||||||
|
"file_delete_failed" to "The file couldn't be deleted.",
|
||||||
"album_not_found" to "That album no longer exists.",
|
"album_not_found" to "That album no longer exists.",
|
||||||
"artist_not_found" to "That artist no longer exists.",
|
"artist_not_found" to "That artist no longer exists.",
|
||||||
"playlist_not_found" to "That playlist no longer exists.",
|
"playlist_not_found" to "That playlist no longer exists.",
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ object NetworkModule {
|
|||||||
.addInterceptor(auth)
|
.addInterceptor(auth)
|
||||||
.addInterceptor(baseUrl)
|
.addInterceptor(baseUrl)
|
||||||
.addInterceptor(logging)
|
.addInterceptor(logging)
|
||||||
|
// A network interceptor, so it sees the address the connection
|
||||||
|
// really reached and runs before any request byte is written.
|
||||||
|
.addNetworkInterceptor(CleartextGuardInterceptor())
|
||||||
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -29,11 +29,20 @@ interface CastApi {
|
|||||||
* Request body. [expSeconds] is clamped server-side to [60, 86400];
|
* Request body. [expSeconds] is clamped server-side to [60, 86400];
|
||||||
* the 21_600 default (6h) is long enough to play through any typical
|
* the 21_600 default (6h) is long enough to play through any typical
|
||||||
* track without re-minting mid-playback.
|
* track without re-minting mid-playback.
|
||||||
|
*
|
||||||
|
* [level] asks for the leveled stream (M464 #5001): the track rendered at
|
||||||
|
* the user's loudness gain, which the server works out from their setting.
|
||||||
|
* [asAlbum] says the track plays among its album in order, which picks
|
||||||
|
* album gain in auto mode. [prerender] says the speaker will fetch it
|
||||||
|
* soon, so the server renders it ahead.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class StreamTokenRequest(
|
data class StreamTokenRequest(
|
||||||
val trackId: String,
|
val trackId: String,
|
||||||
val expSeconds: Int = 21_600,
|
val expSeconds: Int = 21_600,
|
||||||
|
val level: Boolean = false,
|
||||||
|
val asAlbum: Boolean = false,
|
||||||
|
val prerender: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,4 +62,6 @@ data class StreamTokenResponse(
|
|||||||
val url: String,
|
val url: String,
|
||||||
val mime: String = "audio/mpeg",
|
val mime: String = "audio/mpeg",
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
|
/** [url] is the leveled stream; false when leveling is off or changes nothing. */
|
||||||
|
val leveled: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.fabledsword.minstrel.api.endpoints
|
|||||||
import com.fabledsword.minstrel.models.wire.ListenBrainzStatusWire
|
import com.fabledsword.minstrel.models.wire.ListenBrainzStatusWire
|
||||||
import com.fabledsword.minstrel.models.wire.MyProfileWire
|
import com.fabledsword.minstrel.models.wire.MyProfileWire
|
||||||
import com.fabledsword.minstrel.models.wire.SystemPlaylistsStatusWire
|
import com.fabledsword.minstrel.models.wire.SystemPlaylistsStatusWire
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
@@ -59,6 +60,14 @@ interface MeApi {
|
|||||||
*/
|
*/
|
||||||
@PUT("api/me/listenbrainz")
|
@PUT("api/me/listenbrainz")
|
||||||
suspend fun setListenBrainz(@Body body: ListenBrainzPutBody): ListenBrainzStatusWire
|
suspend fun setListenBrainz(@Body body: ListenBrainzPutBody): ListenBrainzStatusWire
|
||||||
|
|
||||||
|
/** The caller's loudness-normalization preference, or the defaults if never set. */
|
||||||
|
@GET("api/me/normalization")
|
||||||
|
suspend fun getNormalization(): NormalizationPrefs
|
||||||
|
|
||||||
|
/** Replaces the whole preference; returns what the server stored. */
|
||||||
|
@PUT("api/me/normalization")
|
||||||
|
suspend fun putNormalization(@Body body: NormalizationPrefs): NormalizationPrefs
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.fabledsword.minstrel.api.endpoints
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.models.wire.ReplayGainResponseWire
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
|
/** The player's loudness lookup (#4997), kept apart from the browse surface in [LibraryApi]. */
|
||||||
|
interface ReplayGainApi {
|
||||||
|
/**
|
||||||
|
* ReplayGain values for up to 200 comma-separated track ids. An id
|
||||||
|
* missing from `items` has not been measured yet.
|
||||||
|
*/
|
||||||
|
@GET("api/tracks/replay-gain")
|
||||||
|
suspend fun getReplayGain(@Query("ids") ids: String): ReplayGainResponseWire
|
||||||
|
}
|
||||||
@@ -4,12 +4,19 @@ import com.fabledsword.minstrel.cache.audiocache.CacheSettings
|
|||||||
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||||
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -27,6 +34,14 @@ import javax.inject.Singleton
|
|||||||
* in-memory state changes synchronously so the next interceptor read
|
* in-memory state changes synchronously so the next interceptor read
|
||||||
* sees the new value immediately; the DAO write coroutine catches up
|
* sees the new value immediately; the DAO write coroutine catches up
|
||||||
* shortly after.
|
* shortly after.
|
||||||
|
*
|
||||||
|
* **The session cookie is the exception** (M462 #4985): it is persisted
|
||||||
|
* through [SessionVault] (Keystore-encrypted), not the Room row. On the
|
||||||
|
* first launch after the upgrade, a cookie still in the row is moved into
|
||||||
|
* the vault and the column cleared, so nobody is signed out by the change.
|
||||||
|
* If the Keystore cannot be used on a device, the cookie stays in the row
|
||||||
|
* as before rather than being lost. [awaitSessionHydrated] lets a caller
|
||||||
|
* that needs a definitive answer (the auth gate) wait for this.
|
||||||
*/
|
*/
|
||||||
// AuthStore is the single-row facade over auth_session (de-facto
|
// AuthStore is the single-row facade over auth_session (de-facto
|
||||||
// app_preferences — see entity comment). It legitimately owns one
|
// app_preferences — see entity comment). It legitimately owns one
|
||||||
@@ -39,6 +54,7 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class AuthStore @Inject constructor(
|
class AuthStore @Inject constructor(
|
||||||
private val dao: AuthSessionDao,
|
private val dao: AuthSessionDao,
|
||||||
|
private val vault: SessionVault,
|
||||||
@ApplicationScope private val scope: CoroutineScope,
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
) {
|
) {
|
||||||
private val sessionCookieState = MutableStateFlow<String?>(null)
|
private val sessionCookieState = MutableStateFlow<String?>(null)
|
||||||
@@ -62,18 +78,32 @@ class AuthStore @Inject constructor(
|
|||||||
private val diagnosticsOptOutState = MutableStateFlow(false)
|
private val diagnosticsOptOutState = MutableStateFlow(false)
|
||||||
val diagnosticsOptOut: StateFlow<Boolean> = diagnosticsOptOutState.asStateFlow()
|
val diagnosticsOptOut: StateFlow<Boolean> = diagnosticsOptOutState.asStateFlow()
|
||||||
|
|
||||||
|
private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT)
|
||||||
|
val normalization: StateFlow<NormalizationPrefs> = normalizationState.asStateFlow()
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
// Serialises every cookie persist with the one-time hydration, so a
|
||||||
|
// sign-in or a 401 that lands while hydration runs is never overwritten
|
||||||
|
// by the stale value hydration read.
|
||||||
|
private val cookieLock = Mutex()
|
||||||
|
|
||||||
|
// Set by setSessionCookie. Once something has written the cookie this
|
||||||
|
// process, that value wins over whatever hydration finds on disk.
|
||||||
|
@Volatile private var cookieTouched = false
|
||||||
|
|
||||||
|
private val cookieHydration: Deferred<Unit> = scope.async { hydrateSessionCookie() }
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
dao.observe().collect { row ->
|
dao.observe().collect { row ->
|
||||||
sessionCookieState.value = row?.sessionCookie
|
|
||||||
baseUrlState.value = row?.baseUrl ?: DEFAULT_BASE_URL
|
baseUrlState.value = row?.baseUrl ?: DEFAULT_BASE_URL
|
||||||
userJsonState.value = row?.userJson
|
userJsonState.value = row?.userJson
|
||||||
themeModeState.value = row?.themeMode
|
themeModeState.value = row?.themeMode
|
||||||
clientIdState.value = row?.clientId
|
clientIdState.value = row?.clientId
|
||||||
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
||||||
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
|
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
|
||||||
|
normalizationState.value = decodeNormalization(row?.normalizationJson)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,9 +115,57 @@ class AuthStore @Inject constructor(
|
|||||||
}.getOrDefault(CacheSettings.DEFAULT)
|
}.getOrDefault(CacheSettings.DEFAULT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun decodeNormalization(raw: String?): NormalizationPrefs {
|
||||||
|
if (raw.isNullOrEmpty()) return NormalizationPrefs.DEFAULT
|
||||||
|
return runCatching {
|
||||||
|
json.decodeFromString(NormalizationPrefs.serializer(), raw)
|
||||||
|
}.getOrDefault(NormalizationPrefs.DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspends until the stored session cookie has been loaded into
|
||||||
|
* [sessionCookie], or [HYDRATION_DEADLINE_MS] passes (rule 156: a wedged
|
||||||
|
* Keystore must not leave the start screen spinning). Returns false on
|
||||||
|
* the deadline; the caller then decides from whatever has loaded, and a
|
||||||
|
* late hydration still lands in [sessionCookie].
|
||||||
|
*/
|
||||||
|
suspend fun awaitSessionHydrated(): Boolean {
|
||||||
|
val done = withTimeoutOrNull(HYDRATION_DEADLINE_MS) { cookieHydration.await() } != null
|
||||||
|
if (!done) Timber.w("auth store: session hydration passed its deadline; deciding without it")
|
||||||
|
return done
|
||||||
|
}
|
||||||
|
|
||||||
fun setSessionCookie(value: String?) {
|
fun setSessionCookie(value: String?) {
|
||||||
|
cookieTouched = true
|
||||||
sessionCookieState.value = value
|
sessionCookieState.value = value
|
||||||
scope.launch { persistCookie(value) }
|
scope.launch { cookieLock.withLock { storeCookie(value) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun hydrateSessionCookie() = cookieLock.withLock {
|
||||||
|
val legacy = runCatching { dao.get()?.sessionCookie }.getOrNull()
|
||||||
|
if (cookieTouched) return@withLock
|
||||||
|
// A cookie in the row is the newer one when both exist: the row is
|
||||||
|
// only written when the vault failed, and an install upgrading from
|
||||||
|
// before the vault has nothing in the vault yet.
|
||||||
|
val cookie = legacy ?: vault.read()
|
||||||
|
// Best-effort: if moving it fails, the session still loads this time
|
||||||
|
// and the move is retried on the next launch. Hydration must never
|
||||||
|
// throw, or awaitSessionHydrated would leave the auth gate stuck.
|
||||||
|
if (legacy != null) {
|
||||||
|
runCatching { storeCookie(legacy) }
|
||||||
|
.onFailure { Timber.w(it, "auth store: could not move the session cookie into the vault") }
|
||||||
|
}
|
||||||
|
sessionCookieState.value = cookie
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vault first; the Room row only when the Keystore is unusable, so a
|
||||||
|
// broken Keystore degrades to the old storage rather than a sign-out.
|
||||||
|
private suspend fun storeCookie(value: String?) {
|
||||||
|
if (vault.write(value)) {
|
||||||
|
if (dao.get()?.sessionCookie != null) dao.setSessionCookie(null)
|
||||||
|
} else {
|
||||||
|
persistLegacyCookie(value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setBaseUrl(value: String) {
|
fun setBaseUrl(value: String) {
|
||||||
@@ -121,7 +199,13 @@ class AuthStore @Inject constructor(
|
|||||||
scope.launch { persistDiagnosticsOptOut(value) }
|
scope.launch { persistDiagnosticsOptOut(value) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun persistCookie(value: String?) {
|
fun setNormalization(value: NormalizationPrefs) {
|
||||||
|
normalizationState.value = value
|
||||||
|
val encoded = json.encodeToString(NormalizationPrefs.serializer(), value)
|
||||||
|
scope.launch { persistNormalization(encoded) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun persistLegacyCookie(value: String?) {
|
||||||
if (dao.get() == null) {
|
if (dao.get() == null) {
|
||||||
dao.upsert(currentEntity().copy(sessionCookie = value))
|
dao.upsert(currentEntity().copy(sessionCookie = value))
|
||||||
} else {
|
} else {
|
||||||
@@ -177,9 +261,19 @@ class AuthStore @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun persistNormalization(json: String) {
|
||||||
|
if (dao.get() == null) {
|
||||||
|
dao.upsert(currentEntity().copy(normalizationJson = json))
|
||||||
|
} else {
|
||||||
|
dao.setNormalizationJson(json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
|
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
|
||||||
id = ROW_ID,
|
id = ROW_ID,
|
||||||
sessionCookie = sessionCookieState.value,
|
// Never copied into the row: the cookie lives in the vault, and
|
||||||
|
// persistLegacyCookie sets it explicitly on the fallback path.
|
||||||
|
sessionCookie = null,
|
||||||
baseUrl = baseUrlState.value,
|
baseUrl = baseUrlState.value,
|
||||||
userJson = userJsonState.value,
|
userJson = userJsonState.value,
|
||||||
themeMode = themeModeState.value,
|
themeMode = themeModeState.value,
|
||||||
@@ -189,10 +283,18 @@ class AuthStore @Inject constructor(
|
|||||||
cacheSettingsState.value,
|
cacheSettingsState.value,
|
||||||
),
|
),
|
||||||
diagnosticsOptOut = diagnosticsOptOutState.value,
|
diagnosticsOptOut = diagnosticsOptOutState.value,
|
||||||
|
normalizationJson = json.encodeToString(
|
||||||
|
NormalizationPrefs.serializer(),
|
||||||
|
normalizationState.value,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val DEFAULT_BASE_URL: String = "http://localhost:8080"
|
const val DEFAULT_BASE_URL: String = "http://localhost:8080"
|
||||||
|
|
||||||
|
// Generous on purpose: hydration is one local row read and one
|
||||||
|
// Keystore decrypt, normally milliseconds. This only bounds "never".
|
||||||
|
const val HYDRATION_DEADLINE_MS: Long = 10_000
|
||||||
private const val ROW_ID = 0
|
private const val ROW_ID = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package com.fabledsword.minstrel.auth
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import dagger.Binds
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.util.Base64
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the session cookie lives at rest (M462 #4985).
|
||||||
|
*
|
||||||
|
* The cookie is a bearer credential: anyone holding it is signed in as the
|
||||||
|
* user until the server expires or revokes it. It used to sit in plain text
|
||||||
|
* in the Room `auth_session` row, readable from any copy of the app's data
|
||||||
|
* directory (a rooted device, an adb backup of a debuggable build, a
|
||||||
|
* forensic image). Now only ciphertext is stored, under an AES key that
|
||||||
|
* lives in the Android Keystore and never leaves it, so a copy of the
|
||||||
|
* files alone yields nothing usable.
|
||||||
|
*/
|
||||||
|
interface SessionVault {
|
||||||
|
/** The stored cookie, or null when none is stored or it can't be decrypted. */
|
||||||
|
fun read(): String?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores [value], or clears the stored cookie when null. Returns false
|
||||||
|
* when the Keystore could not be used, so the caller can fall back
|
||||||
|
* rather than lose the session.
|
||||||
|
*/
|
||||||
|
fun write(value: String?): Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AES-GCM sealing of a short string, framed as base64(iv || ciphertext+tag).
|
||||||
|
* Kept apart from the Keystore so the framing can be unit-tested on the JVM
|
||||||
|
* with an ordinary key; the Android Keystore has no JVM implementation.
|
||||||
|
*/
|
||||||
|
internal object SealedBox {
|
||||||
|
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||||
|
private const val TAG_BITS = 128
|
||||||
|
private const val IV_BYTES = 12
|
||||||
|
|
||||||
|
// Binds a sealed value to its purpose: a blob sealed for something else
|
||||||
|
// under the same key will not open as a session cookie.
|
||||||
|
private val AAD = "minstrel-session-cookie-v1".toByteArray(Charsets.UTF_8)
|
||||||
|
|
||||||
|
fun seal(key: SecretKey, plaintext: String): String {
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
// No IV passed: the provider generates a fresh random one. Keystore
|
||||||
|
// keys refuse a caller-chosen IV for encryption by default.
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||||
|
cipher.updateAAD(AAD)
|
||||||
|
val sealed = cipher.iv + cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
|
||||||
|
return Base64.getEncoder().encodeToString(sealed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun open(key: SecretKey, sealed: String): String {
|
||||||
|
val bytes = Base64.getDecoder().decode(sealed)
|
||||||
|
require(bytes.size > IV_BYTES) { "sealed value too short" }
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, bytes, 0, IV_BYTES))
|
||||||
|
cipher.updateAAD(AAD)
|
||||||
|
return String(cipher.doFinal(bytes, IV_BYTES, bytes.size - IV_BYTES), Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [SessionVault] backed by a Keystore AES key and a private prefs file that
|
||||||
|
* holds only the sealed value.
|
||||||
|
*
|
||||||
|
* A value that will not open (the key was wiped by a factory-reset of the
|
||||||
|
* Keystore, or the file was restored onto another device; app backup is off,
|
||||||
|
* but a vendor transfer tool may still copy files) is discarded and reported
|
||||||
|
* as absent. The user signs in again, which is the right outcome for a
|
||||||
|
* credential that no longer verifies.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class KeystoreSessionVault @Inject constructor(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
) : SessionVault {
|
||||||
|
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
override fun read(): String? {
|
||||||
|
val sealed = prefs.getString(KEY_COOKIE, null) ?: return null
|
||||||
|
return runCatching { SealedBox.open(key(), sealed) }
|
||||||
|
.onFailure {
|
||||||
|
Timber.w(it, "session vault: stored cookie would not decrypt; discarding it")
|
||||||
|
prefs.edit().remove(KEY_COOKIE).commit()
|
||||||
|
}
|
||||||
|
.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun write(value: String?): Boolean = runCatching {
|
||||||
|
val editor = prefs.edit()
|
||||||
|
if (value == null) {
|
||||||
|
editor.remove(KEY_COOKIE)
|
||||||
|
} else {
|
||||||
|
editor.putString(KEY_COOKIE, SealedBox.seal(key(), value))
|
||||||
|
}
|
||||||
|
editor.commit()
|
||||||
|
}.onFailure {
|
||||||
|
Timber.w(it, "session vault: Keystore unavailable; cookie not stored in the vault")
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
private fun key(): SecretKey {
|
||||||
|
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||||
|
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
|
||||||
|
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
|
||||||
|
generator.init(
|
||||||
|
KeyGenParameterSpec.Builder(
|
||||||
|
KEY_ALIAS,
|
||||||
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||||
|
)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.setKeySize(KEY_BITS)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
return generator.generateKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||||
|
const val KEY_ALIAS = "minstrel_session_cookie"
|
||||||
|
const val KEY_BITS = 256
|
||||||
|
const val PREFS_NAME = "session_vault"
|
||||||
|
const val KEY_COOKIE = "sealed_cookie"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
abstract class SessionVaultModule {
|
||||||
|
@Binds
|
||||||
|
abstract fun bindSessionVault(impl: KeystoreSessionVault): SessionVault
|
||||||
|
}
|
||||||
@@ -16,10 +16,11 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes the initial startDestination for the root NavHost based on
|
* Computes the initial startDestination for the root NavHost based on
|
||||||
* persisted auth state. Sits on top of AuthSessionDao directly rather
|
* persisted auth state. Reads the row from AuthSessionDao directly, and
|
||||||
* than AuthStore's StateFlow because the StateFlow defaults to null
|
* the session cookie only after [AuthStore.awaitSessionHydrated]: both
|
||||||
* until Room's first async emission — we need a definitive answer
|
* StateFlows default to null until their async load lands, and we need a
|
||||||
* before drawing any nav graph.
|
* definitive answer before drawing any nav graph. The cookie is no longer
|
||||||
|
* in the row (it lives in the Keystore-backed SessionVault, #4985).
|
||||||
*
|
*
|
||||||
* - no row at all → ServerUrl (first launch)
|
* - no row at all → ServerUrl (first launch)
|
||||||
* - row with baseUrl, no cookie → Login (URL configured, not yet signed in)
|
* - row with baseUrl, no cookie → Login (URL configured, not yet signed in)
|
||||||
@@ -31,6 +32,7 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AuthGateViewModel @Inject constructor(
|
class AuthGateViewModel @Inject constructor(
|
||||||
private val dao: AuthSessionDao,
|
private val dao: AuthSessionDao,
|
||||||
|
private val authStore: AuthStore,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<Any?>(null)
|
private val internal = MutableStateFlow<Any?>(null)
|
||||||
@@ -38,12 +40,13 @@ class AuthGateViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
authStore.awaitSessionHydrated()
|
||||||
|
val signedIn = !authStore.sessionCookie.value.isNullOrEmpty()
|
||||||
val row = dao.get()
|
val row = dao.get()
|
||||||
internal.value = when {
|
internal.value = when {
|
||||||
row == null -> ServerUrl
|
row == null -> ServerUrl
|
||||||
row.baseUrl == AuthStore.DEFAULT_BASE_URL && row.sessionCookie.isNullOrEmpty() ->
|
row.baseUrl == AuthStore.DEFAULT_BASE_URL && !signedIn -> ServerUrl
|
||||||
ServerUrl
|
!signedIn -> Login
|
||||||
row.sessionCookie.isNullOrEmpty() -> Login
|
|
||||||
else -> Home
|
else -> Home
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-1
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.cache.db
|
|||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao
|
import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||||
@@ -65,13 +67,22 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
|||||||
AuthSessionEntity::class,
|
AuthSessionEntity::class,
|
||||||
DiagnosticEventEntity::class,
|
DiagnosticEventEntity::class,
|
||||||
],
|
],
|
||||||
|
// v10: + cached_tracks.trackGain/trackPeak and cached_albums.albumGain/
|
||||||
|
// albumPeak, the ReplayGain values the player levels by (M464 #5000).
|
||||||
|
// MIGRATION_9_10 also rewinds the sync cursor, so the next sync re-sends
|
||||||
|
// every row and an existing cache gains its values.
|
||||||
|
// v9: + auth_session.normalizationJson, the loudness-normalization
|
||||||
|
// preference (M464 #4998). The first schema step with an explicit
|
||||||
|
// Migration (MIGRATION_8_9): a destructive rebuild would also wipe this
|
||||||
|
// row — the server address and theme — and the queued offline writes,
|
||||||
|
// which is too much to lose for one added column.
|
||||||
// v8: + cached_tracks.missing, the server's missing-file mark (#2704),
|
// v8: + cached_tracks.missing, the server's missing-file mark (#2704),
|
||||||
// so cache-first surfaces stop offering files that cannot stream.
|
// so cache-first surfaces stop offering files that cannot stream.
|
||||||
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
|
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
|
||||||
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
|
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
|
||||||
// which is exactly right here: the next sync refills every row with the
|
// which is exactly right here: the next sync refills every row with the
|
||||||
// new column populated, so there is nothing to migrate by hand.
|
// new column populated, so there is nothing to migrate by hand.
|
||||||
version = 8,
|
version = 10,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@TypeConverters(MinstrelTypeConverters::class)
|
@TypeConverters(MinstrelTypeConverters::class)
|
||||||
@@ -92,3 +103,25 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun authSessionDao(): AuthSessionDao
|
abstract fun authSessionDao(): AuthSessionDao
|
||||||
abstract fun diagnosticEventDao(): DiagnosticEventDao
|
abstract fun diagnosticEventDao(): DiagnosticEventDao
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** v8 → v9: add the nullable normalization preference column (#4998). */
|
||||||
|
val MIGRATION_8_9: Migration = object : Migration(8, 9) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE auth_session ADD COLUMN normalizationJson TEXT")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v9 → v10: the gain columns (#5000). Rows synced before this carry no gains,
|
||||||
|
* and the sync is incremental, so it would never re-send them: the cursor goes
|
||||||
|
* back to 0 and the next sync is a full one, upserting every row in place.
|
||||||
|
*/
|
||||||
|
val MIGRATION_9_10: Migration = object : Migration(9, 10) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE cached_tracks ADD COLUMN trackGain REAL")
|
||||||
|
db.execSQL("ALTER TABLE cached_tracks ADD COLUMN trackPeak REAL")
|
||||||
|
db.execSQL("ALTER TABLE cached_albums ADD COLUMN albumGain REAL")
|
||||||
|
db.execSQL("ALTER TABLE cached_albums ADD COLUMN albumPeak REAL")
|
||||||
|
db.execSQL("UPDATE sync_metadata SET cursor = 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ object DatabaseModule {
|
|||||||
// launch, so users lose only the unsynced mutation queue
|
// launch, so users lose only the unsynced mutation queue
|
||||||
// (acceptable while we're iterating). Replace with explicit
|
// (acceptable while we're iterating). Replace with explicit
|
||||||
// Migration entries before the first tagged release.
|
// Migration entries before the first tagged release.
|
||||||
|
.addMigrations(MIGRATION_8_9, MIGRATION_9_10)
|
||||||
.fallbackToDestructiveMigration(dropAllTables = true)
|
.fallbackToDestructiveMigration(dropAllTables = true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
|||||||
+4
@@ -46,4 +46,8 @@ interface AuthSessionDao {
|
|||||||
/** Partial update: change only the per-device diagnostics opt-out. */
|
/** Partial update: change only the per-device diagnostics opt-out. */
|
||||||
@Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0")
|
@Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0")
|
||||||
suspend fun setDiagnosticsOptOut(optOut: Boolean)
|
suspend fun setDiagnosticsOptOut(optOut: Boolean)
|
||||||
|
|
||||||
|
/** Partial update: change only the serialized normalization preference. */
|
||||||
|
@Query("UPDATE auth_session SET normalizationJson = :json WHERE id = 0")
|
||||||
|
suspend fun setNormalizationJson(json: String?)
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -31,4 +31,8 @@ interface CachedMutationDao {
|
|||||||
|
|
||||||
@Query("DELETE FROM cached_mutations")
|
@Query("DELETE FROM cached_mutations")
|
||||||
suspend fun clear()
|
suspend fun clear()
|
||||||
|
|
||||||
|
/** Whether a write of [kind] is still waiting to be replayed. */
|
||||||
|
@Query("SELECT EXISTS(SELECT 1 FROM cached_mutations WHERE kind = :kind)")
|
||||||
|
suspend fun hasPending(kind: String): Boolean
|
||||||
}
|
}
|
||||||
|
|||||||
+21
@@ -38,6 +38,27 @@ interface CachedTrackDao {
|
|||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun upsertAll(rows: List<CachedTrackEntity>)
|
suspend fun upsertAll(rows: List<CachedTrackEntity>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ReplayGain values for [ids] (M464 #5000): the track's own from its row,
|
||||||
|
* the album's from its album row. A track not in the cache has no row.
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"SELECT t.id AS id, t.trackGain AS trackGain, t.trackPeak AS trackPeak, " +
|
||||||
|
"a.albumGain AS albumGain, a.albumPeak AS albumPeak " +
|
||||||
|
"FROM cached_tracks t LEFT JOIN cached_albums a ON a.id = t.albumId " +
|
||||||
|
"WHERE t.id IN (:ids)",
|
||||||
|
)
|
||||||
|
suspend fun replayGains(ids: List<String>): List<CachedReplayGain>
|
||||||
|
|
||||||
@Query("DELETE FROM cached_tracks WHERE id IN (:ids)")
|
@Query("DELETE FROM cached_tracks WHERE id IN (:ids)")
|
||||||
suspend fun deleteByIds(ids: List<String>)
|
suspend fun deleteByIds(ids: List<String>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One row of [CachedTrackDao.replayGains]. */
|
||||||
|
data class CachedReplayGain(
|
||||||
|
val id: String,
|
||||||
|
val trackGain: Float?,
|
||||||
|
val trackPeak: Float?,
|
||||||
|
val albumGain: Float?,
|
||||||
|
val albumPeak: Float?,
|
||||||
|
)
|
||||||
|
|||||||
+6
@@ -43,4 +43,10 @@ data class AuthSessionEntity(
|
|||||||
* choice lives here. Default false = honor the account flag.
|
* choice lives here. Default false = honor the account flag.
|
||||||
*/
|
*/
|
||||||
val diagnosticsOptOut: Boolean = false,
|
val diagnosticsOptOut: Boolean = false,
|
||||||
|
/**
|
||||||
|
* JSON-encoded NormalizationPrefs (settings/data), the last value seen
|
||||||
|
* from the server or set here (M464 #4998). Null = never fetched; the
|
||||||
|
* defaults apply. Kept so offline playback still levels.
|
||||||
|
*/
|
||||||
|
val normalizationJson: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
+3
@@ -18,5 +18,8 @@ data class CachedAlbumEntity(
|
|||||||
val releaseDate: String? = null,
|
val releaseDate: String? = null,
|
||||||
val coverPath: String? = null,
|
val coverPath: String? = null,
|
||||||
val mbid: String? = null,
|
val mbid: String? = null,
|
||||||
|
// ReplayGain 2.0 album values (M464); null until every track is measured.
|
||||||
|
val albumGain: Float? = null,
|
||||||
|
val albumPeak: Float? = null,
|
||||||
val fetchedAt: Instant = Clock.System.now(),
|
val fetchedAt: Instant = Clock.System.now(),
|
||||||
)
|
)
|
||||||
|
|||||||
+4
@@ -26,5 +26,9 @@ data class CachedTrackEntity(
|
|||||||
val fileFormat: String? = null,
|
val fileFormat: String? = null,
|
||||||
val genre: String? = null,
|
val genre: String? = null,
|
||||||
val missing: Boolean = false,
|
val missing: Boolean = false,
|
||||||
|
// ReplayGain 2.0 track values (M464), kept so cached audio levels
|
||||||
|
// offline. Null until the server has measured the track.
|
||||||
|
val trackGain: Float? = null,
|
||||||
|
val trackPeak: Float? = null,
|
||||||
val fetchedAt: Instant = Clock.System.now(),
|
val fetchedAt: Instant = Clock.System.now(),
|
||||||
)
|
)
|
||||||
|
|||||||
+13
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.cache.mutations
|
|||||||
|
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
@@ -41,6 +42,11 @@ object MutationKind {
|
|||||||
// an undo collapses to the latest intent instead of replaying as two
|
// an undo collapses to the latest intent instead of replaying as two
|
||||||
// opposed calls whose order decides the outcome.
|
// opposed calls whose order decides the outcome.
|
||||||
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
|
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
|
||||||
|
|
||||||
|
// M464 #4998 loudness-normalization preference. The payload is the whole
|
||||||
|
// preference, a target state like the toggles above, so queued changes
|
||||||
|
// collapse to the last one and an older one can never be replayed last.
|
||||||
|
const val NORMALIZATION_SET: String = "normalization_set"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,6 +91,7 @@ data class RequestCreatePayload(
|
|||||||
* This matches `feedback_offline_first_for_server_writes` — writes
|
* This matches `feedback_offline_first_for_server_writes` — writes
|
||||||
* never go fire-and-forget.
|
* never go fire-and-forget.
|
||||||
*/
|
*/
|
||||||
|
@Suppress("TooManyFunctions") // one enqueue per mutation kind, like the replayer's dispatchers
|
||||||
@Singleton
|
@Singleton
|
||||||
class MutationQueue @Inject constructor(
|
class MutationQueue @Inject constructor(
|
||||||
private val dao: CachedMutationDao,
|
private val dao: CachedMutationDao,
|
||||||
@@ -177,6 +184,12 @@ class MutationQueue @Inject constructor(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Queues the user's whole normalization preference for replay. */
|
||||||
|
suspend fun enqueueNormalizationSet(prefs: NormalizationPrefs): Long = insertUserDriven(
|
||||||
|
MutationKind.NORMALIZATION_SET,
|
||||||
|
json.encodeToString(NormalizationPrefs.serializer(), prefs),
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
||||||
MutationKind.REQUEST_CANCEL,
|
MutationKind.REQUEST_CANCEL,
|
||||||
json.encodeToString(
|
json.encodeToString(
|
||||||
|
|||||||
+21
-1
@@ -7,6 +7,7 @@ import com.fabledsword.minstrel.api.endpoints.DiscoverApi
|
|||||||
import com.fabledsword.minstrel.api.endpoints.EventsApi
|
import com.fabledsword.minstrel.api.endpoints.EventsApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.FlagRequest
|
import com.fabledsword.minstrel.api.endpoints.FlagRequest
|
||||||
import com.fabledsword.minstrel.api.endpoints.LikesApi
|
import com.fabledsword.minstrel.api.endpoints.LikesApi
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.MeApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
|
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
|
||||||
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
|
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
||||||
@@ -22,6 +23,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
|||||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
@@ -79,6 +81,7 @@ class MutationReplayer @Inject constructor(
|
|||||||
private val eventsApi: EventsApi = retrofit.create()
|
private val eventsApi: EventsApi = retrofit.create()
|
||||||
private val requestsApi: RequestsApi = retrofit.create()
|
private val requestsApi: RequestsApi = retrofit.create()
|
||||||
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
|
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
|
||||||
|
private val meApi: MeApi = retrofit.create()
|
||||||
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
@@ -166,6 +169,7 @@ class MutationReplayer @Inject constructor(
|
|||||||
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
||||||
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
||||||
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
|
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
|
||||||
|
MutationKind.NORMALIZATION_SET -> dispatchNormalizationSet(row.payload)
|
||||||
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
||||||
else -> Outcome.DROP
|
else -> Outcome.DROP
|
||||||
}
|
}
|
||||||
@@ -279,6 +283,16 @@ class MutationReplayer @Inject constructor(
|
|||||||
return Outcome.SENT
|
return Outcome.SENT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the queued normalization preference. The device already shows
|
||||||
|
* it, so the server's echo is not written back: a change made since the
|
||||||
|
* row was queued would be a newer row, and the collapse keeps only that.
|
||||||
|
*/
|
||||||
|
private suspend fun dispatchNormalizationSet(payload: String): Outcome {
|
||||||
|
meApi.putNormalization(json.decodeFromString(NormalizationPrefs.serializer(), payload))
|
||||||
|
return Outcome.SENT
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
||||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||||
playbackErrorsApi.report(
|
playbackErrorsApi.report(
|
||||||
@@ -303,7 +317,8 @@ class MutationReplayer @Inject constructor(
|
|||||||
/**
|
/**
|
||||||
* Row ids of desired-state toggles superseded by a later toggle for the same
|
* Row ids of desired-state toggles superseded by a later toggle for the same
|
||||||
* entity. Applies to every kind whose payload encodes a TARGET state rather
|
* entity. Applies to every kind whose payload encodes a TARGET state rather
|
||||||
* than an action — like-toggles and suggestion snoozes (#2374) — because
|
* than an action — like-toggles, suggestion snoozes (#2374) and the
|
||||||
|
* normalization preference (#4998) — because
|
||||||
* replaying a stale one last would invert the final state.
|
* replaying a stale one last would invert the final state.
|
||||||
*
|
*
|
||||||
* Top-level and pure so it can be unit-tested without standing up a Retrofit
|
* Top-level and pure so it can be unit-tested without standing up a Retrofit
|
||||||
@@ -340,5 +355,10 @@ private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (
|
|||||||
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
|
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
|
||||||
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
|
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
|
||||||
|
|
||||||
|
// One preference per user, so every normalization row shares one key.
|
||||||
|
MutationKind.NORMALIZATION_SET -> runCatching {
|
||||||
|
json.decodeFromString(NormalizationPrefs.serializer(), row.payload)
|
||||||
|
}.getOrNull()?.let { row.kind }
|
||||||
|
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -206,6 +206,8 @@ private fun SyncAlbumWire.toEntity(): CachedAlbumEntity = CachedAlbumEntity(
|
|||||||
releaseDate = releaseDate,
|
releaseDate = releaseDate,
|
||||||
coverPath = coverArtPath,
|
coverPath = coverArtPath,
|
||||||
mbid = mbid,
|
mbid = mbid,
|
||||||
|
albumGain = albumGain,
|
||||||
|
albumPeak = albumPeak,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
|
private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
|
||||||
@@ -220,4 +222,6 @@ private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
|
|||||||
fileFormat = fileFormat,
|
fileFormat = fileFormat,
|
||||||
genre = genre,
|
genre = genre,
|
||||||
missing = missing,
|
missing = missing,
|
||||||
|
trackGain = trackGain,
|
||||||
|
trackPeak = trackPeak,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.fabledsword.minstrel.models.wire
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** One track's entry in `GET /api/tracks/replay-gain` (#4997). Decode-only. */
|
||||||
|
@Serializable
|
||||||
|
data class ReplayGainWire(
|
||||||
|
@SerialName("track_gain") val trackGain: Float? = null,
|
||||||
|
@SerialName("track_peak") val trackPeak: Float? = null,
|
||||||
|
@SerialName("album_gain") val albumGain: Float? = null,
|
||||||
|
@SerialName("album_peak") val albumPeak: Float? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ReplayGainResponseWire(
|
||||||
|
val items: Map<String, ReplayGainWire> = emptyMap(),
|
||||||
|
)
|
||||||
@@ -28,6 +28,10 @@ data class SyncAlbumWire(
|
|||||||
@SerialName("release_date") val releaseDate: String? = null,
|
@SerialName("release_date") val releaseDate: String? = null,
|
||||||
@SerialName("cover_art_path") val coverArtPath: String? = null,
|
@SerialName("cover_art_path") val coverArtPath: String? = null,
|
||||||
val mbid: String? = null,
|
val mbid: String? = null,
|
||||||
|
// The album's ReplayGain 2.0 values (#4997): dB to -18 LUFS and a linear
|
||||||
|
// peak. Null until every track on the album is measured.
|
||||||
|
@SerialName("album_gain") val albumGain: Float? = null,
|
||||||
|
@SerialName("album_peak") val albumPeak: Float? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -51,6 +55,9 @@ data class SyncTrackWire(
|
|||||||
// its tracks stay playable, which is the correct reading of "this server
|
// its tracks stay playable, which is the correct reading of "this server
|
||||||
// has nothing to say about missing files".
|
// has nothing to say about missing files".
|
||||||
val missing: Boolean = false,
|
val missing: Boolean = false,
|
||||||
|
// The track's ReplayGain 2.0 values (#4997); null until it is measured.
|
||||||
|
@SerialName("track_gain") val trackGain: Float? = null,
|
||||||
|
@SerialName("track_peak") val trackPeak: Float? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ class MinstrelForwardingPlayer(
|
|||||||
* one. Diagnostics-only; see [TransportObservation].
|
* one. Diagnostics-only; see [TransportObservation].
|
||||||
*/
|
*/
|
||||||
val onTransport: (TransportObservation) -> Unit = {},
|
val onTransport: (TransportObservation) -> Unit = {},
|
||||||
|
/** The renderer's 1-based queue position, every poll. */
|
||||||
|
val onRendererTrack: (trackNumber: Int) -> Unit = {},
|
||||||
)
|
)
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
@@ -622,6 +624,7 @@ class MinstrelForwardingPlayer(
|
|||||||
trackNumber = info.track,
|
trackNumber = info.track,
|
||||||
)
|
)
|
||||||
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
|
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
|
||||||
|
events.onRendererTrack(info.track)
|
||||||
val transport = active.avTransport.getTransportInfo()
|
val transport = active.avTransport.getTransportInfo()
|
||||||
when (transport.state) {
|
when (transport.state) {
|
||||||
TransportState.PLAYING -> {
|
TransportState.PLAYING -> {
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import androidx.media3.session.SessionCommand
|
|||||||
import com.fabledsword.minstrel.MainActivity
|
import com.fabledsword.minstrel.MainActivity
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK
|
import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationRepository
|
||||||
import com.google.common.collect.ImmutableList
|
import com.google.common.collect.ImmutableList
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
@@ -25,6 +27,7 @@ import kotlinx.coroutines.flow.flatMapLatest
|
|||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,6 +69,8 @@ class MinstrelPlayerService : MediaSessionService() {
|
|||||||
|
|
||||||
@Inject lateinit var likesRepository: LikesRepository
|
@Inject lateinit var likesRepository: LikesRepository
|
||||||
|
|
||||||
|
@Inject lateinit var normalizationRepository: NormalizationRepository
|
||||||
|
|
||||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
|
|
||||||
private var mediaSession: MediaSession? = null
|
private var mediaSession: MediaSession? = null
|
||||||
@@ -82,6 +87,21 @@ class MinstrelPlayerService : MediaSessionService() {
|
|||||||
.build()
|
.build()
|
||||||
mediaSession = session
|
mediaSession = session
|
||||||
serviceScope.launch { observeLikeState(session, player) }
|
serviceScope.launch { observeLikeState(session, player) }
|
||||||
|
serviceScope.launch { refreshNormalization() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Takes up a leveling preference changed on another device (M464 #5000).
|
||||||
|
// Offline, the device's copy stands, which is the one playback reads.
|
||||||
|
private suspend fun refreshNormalization() {
|
||||||
|
try {
|
||||||
|
normalizationRepository.refresh()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
Timber.d(e, "normalization refresh skipped")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import androidx.media3.session.MediaController
|
|||||||
import androidx.media3.session.SessionToken
|
import androidx.media3.session.SessionToken
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
|
import com.fabledsword.minstrel.player.gain.GainAudioProcessor
|
||||||
|
import com.fabledsword.minstrel.player.gain.ReplayGainStore
|
||||||
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
||||||
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
||||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||||
@@ -69,6 +71,7 @@ class PlayerController @Inject constructor(
|
|||||||
private val playerFactory: PlayerFactory,
|
private val playerFactory: PlayerFactory,
|
||||||
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
|
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
|
||||||
private val remoteState: RemotePlayerState,
|
private val remoteState: RemotePlayerState,
|
||||||
|
private val replayGains: ReplayGainStore,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -277,6 +280,11 @@ class PlayerController @Inject constructor(
|
|||||||
queueRefs = playable.tracks
|
queueRefs = playable.tracks
|
||||||
val items = playable.tracks.map { it.toMediaItem(source) }
|
val items = playable.tracks.map { it.toMediaItem(source) }
|
||||||
val startIndex = playable.initialIndex
|
val startIndex = playable.initialIndex
|
||||||
|
// Gains for the first tracks, so the first one levels from its first
|
||||||
|
// sample rather than ramping in once the lookup lands.
|
||||||
|
replayGains.request(
|
||||||
|
playable.tracks.drop(startIndex).take(GAIN_PREFETCH).map { it.id },
|
||||||
|
)
|
||||||
// Drift #562 cold-boot resume calls this from a non-Main suspend
|
// Drift #562 cold-boot resume calls this from a non-Main suspend
|
||||||
// context after awaitReady() unblocks (ResumeController launches
|
// context after awaitReady() unblocks (ResumeController launches
|
||||||
// on Dispatchers.Default by the time it reaches us). MediaController
|
// on Dispatchers.Default by the time it reaches us). MediaController
|
||||||
@@ -792,7 +800,13 @@ class PlayerController @Inject constructor(
|
|||||||
// scrubber a real total even when the wrapped ExoPlayer is
|
// scrubber a real total even when the wrapped ExoPlayer is
|
||||||
// paused under UPnP (it never probes a duration in that state).
|
// paused under UPnP (it never probes a duration in that state).
|
||||||
if (durationSec > 0) setDurationMs(durationSec.toLong() * MS_PER_SECOND)
|
if (durationSec > 0) setDurationMs(durationSec.toLong() * MS_PER_SECOND)
|
||||||
if (source != null) setExtras(sourceExtras(source))
|
// Album and track position let the gain processor tell an
|
||||||
|
// album played in order from a mix (M464 #5000).
|
||||||
|
trackNumber?.let { setTrackNumber(it) }
|
||||||
|
discNumber?.let { setDiscNumber(it) }
|
||||||
|
val extras = GainAudioProcessor.albumExtras(albumId)
|
||||||
|
if (source != null) extras.putAll(sourceExtras(source))
|
||||||
|
setExtras(extras)
|
||||||
// Point the notification / lock-screen art at the SAME album
|
// Point the notification / lock-screen art at the SAME album
|
||||||
// cover the in-app surfaces use (TrackRef.coverUrl ->
|
// cover the in-app surfaces use (TrackRef.coverUrl ->
|
||||||
// /api/albums/{id}/cover). Without this, Media3 falls back to
|
// /api/albums/{id}/cover). Without this, Media3 falls back to
|
||||||
@@ -860,6 +874,7 @@ class PlayerController @Inject constructor(
|
|||||||
const val MINSTREL_SOURCE_KEY: String = "minstrel_source"
|
const val MINSTREL_SOURCE_KEY: String = "minstrel_source"
|
||||||
private const val MS_PER_SECOND = 1_000L
|
private const val MS_PER_SECOND = 1_000L
|
||||||
private const val MAX_INTERPOLATION_DRIFT_MS = 5_000L
|
private const val MAX_INTERPOLATION_DRIFT_MS = 5_000L
|
||||||
|
private const val GAIN_PREFETCH = 20
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import androidx.media3.common.AudioAttributes
|
import androidx.media3.common.AudioAttributes
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.audio.AudioProcessor
|
||||||
import androidx.media3.common.util.BitmapLoader
|
import androidx.media3.common.util.BitmapLoader
|
||||||
import androidx.media3.database.StandaloneDatabaseProvider
|
import androidx.media3.database.StandaloneDatabaseProvider
|
||||||
import androidx.media3.datasource.DataSourceBitmapLoader
|
import androidx.media3.datasource.DataSourceBitmapLoader
|
||||||
@@ -12,11 +13,18 @@ import androidx.media3.datasource.cache.CacheDataSource
|
|||||||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||||
import androidx.media3.datasource.cache.SimpleCache
|
import androidx.media3.datasource.cache.SimpleCache
|
||||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.audio.AudioSink
|
||||||
|
import androidx.media3.exoplayer.audio.DefaultAudioSink
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
import androidx.media3.session.CacheBitmapLoader
|
import androidx.media3.session.CacheBitmapLoader
|
||||||
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.cache.audiocache.CacheConfig
|
import com.fabledsword.minstrel.cache.audiocache.CacheConfig
|
||||||
|
import com.fabledsword.minstrel.player.gain.GainAudioProcessor
|
||||||
|
import com.fabledsword.minstrel.player.gain.ReplayGainStore
|
||||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||||
|
import com.fabledsword.minstrel.player.output.SonosQueueLoader
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
@@ -56,6 +64,9 @@ class PlayerFactory @Inject constructor(
|
|||||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||||
private val remoteState: RemotePlayerState,
|
private val remoteState: RemotePlayerState,
|
||||||
private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
||||||
|
private val authStore: AuthStore,
|
||||||
|
private val replayGains: ReplayGainStore,
|
||||||
|
private val sonosQueue: SonosQueueLoader,
|
||||||
) {
|
) {
|
||||||
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
|
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
|
||||||
|
|
||||||
@@ -120,6 +131,7 @@ class PlayerFactory @Inject constructor(
|
|||||||
onStalled = { trackId -> stallEventsInternal.tryEmit(trackId) },
|
onStalled = { trackId -> stallEventsInternal.tryEmit(trackId) },
|
||||||
onQueueTruncated = { queueRepairInternal.tryEmit(Unit) },
|
onQueueTruncated = { queueRepairInternal.tryEmit(Unit) },
|
||||||
onTransport = { transportInternal.tryEmit(it) },
|
onTransport = { transportInternal.tryEmit(it) },
|
||||||
|
onRendererTrack = { sonosQueue.onRendererTrack(it) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -142,7 +154,28 @@ class PlayerFactory @Inject constructor(
|
|||||||
val mediaSourceFactory = DefaultMediaSourceFactory(context)
|
val mediaSourceFactory = DefaultMediaSourceFactory(context)
|
||||||
.setDataSourceFactory(cacheDataSource)
|
.setDataSourceFactory(cacheDataSource)
|
||||||
|
|
||||||
return ExoPlayer.Builder(context)
|
// Loudness normalization (M464 #5000) runs inside the audio sink so
|
||||||
|
// each track's gain starts on its first sample. Audio offload would
|
||||||
|
// bypass the sink's processors; ExoPlayer leaves it off unless asked
|
||||||
|
// (TrackSelectionParameters.audioOffloadPreferences), and nothing here
|
||||||
|
// asks, so leveling always applies.
|
||||||
|
val gainProcessor = GainAudioProcessor(
|
||||||
|
prefs = { authStore.normalization.value },
|
||||||
|
store = replayGains,
|
||||||
|
)
|
||||||
|
val renderersFactory = object : DefaultRenderersFactory(context) {
|
||||||
|
override fun buildAudioSink(
|
||||||
|
context: Context,
|
||||||
|
enableFloatOutput: Boolean,
|
||||||
|
enableAudioOutputPlaybackParams: Boolean,
|
||||||
|
): AudioSink = DefaultAudioSink.Builder(context)
|
||||||
|
.setEnableFloatOutput(enableFloatOutput)
|
||||||
|
.setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams)
|
||||||
|
.setAudioProcessors(arrayOf<AudioProcessor>(gainProcessor))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
val exo = ExoPlayer.Builder(context, renderersFactory)
|
||||||
.setMediaSourceFactory(mediaSourceFactory)
|
.setMediaSourceFactory(mediaSourceFactory)
|
||||||
.setAudioAttributes(
|
.setAudioAttributes(
|
||||||
AudioAttributes.Builder()
|
AudioAttributes.Builder()
|
||||||
@@ -153,6 +186,17 @@ class PlayerFactory @Inject constructor(
|
|||||||
)
|
)
|
||||||
.setHandleAudioBecomingNoisy(true)
|
.setHandleAudioBecomingNoisy(true)
|
||||||
.build()
|
.build()
|
||||||
|
// The processor finds a track's neighbours in play order, which
|
||||||
|
// depends on shuffle mode.
|
||||||
|
gainProcessor.shuffleEnabled = exo.shuffleModeEnabled
|
||||||
|
exo.addListener(
|
||||||
|
object : Player.Listener {
|
||||||
|
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
|
||||||
|
gainProcessor.shuffleEnabled = shuffleModeEnabled
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return exo
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ import javax.inject.Singleton
|
|||||||
class StreamTokenProvider @Inject constructor(retrofit: Retrofit) {
|
class StreamTokenProvider @Inject constructor(retrofit: Retrofit) {
|
||||||
private val api: CastApi = retrofit.create()
|
private val api: CastApi = retrofit.create()
|
||||||
|
|
||||||
suspend fun mint(trackId: String): StreamTokenResponse =
|
/**
|
||||||
api.streamToken(StreamTokenRequest(trackId = trackId))
|
* Mints a URL for a speaker, leveled when the user's setting calls for
|
||||||
|
* it (M464 #5002). The server answers with the plain stream when it does
|
||||||
|
* not, so every speaker URL asks.
|
||||||
|
*/
|
||||||
|
suspend fun mint(trackId: String, asAlbum: Boolean = false, prerender: Boolean = false): StreamTokenResponse =
|
||||||
|
api.streamToken(
|
||||||
|
StreamTokenRequest(trackId = trackId, level = true, asAlbum = asAlbum, prerender = prerender),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package com.fabledsword.minstrel.player.gain
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.Timeline
|
||||||
|
import androidx.media3.common.audio.AudioProcessor
|
||||||
|
import androidx.media3.common.audio.BaseAudioProcessor
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationBoost
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationMode
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies each track's loudness gain inside ExoPlayer's audio sink (M464
|
||||||
|
* #5000), so the level changes on the exact sample a track starts, gapless
|
||||||
|
* transitions included. `Player.setVolume` could do neither: it stops at 1,
|
||||||
|
* and set from a transition callback it lands about two seconds late
|
||||||
|
* because the next track is already buffered (androidx/media#418).
|
||||||
|
*
|
||||||
|
* Media3 1.11 flushes the sink's processors at every item boundary with the
|
||||||
|
* playlist [Timeline] and the new item's period, which is how this knows
|
||||||
|
* which track it is processing and who its neighbours are.
|
||||||
|
*
|
||||||
|
* Boosts above unity are held under -1 dBFS by a peak limiter when the user
|
||||||
|
* chose one. In headroom mode the gain already stops short of the track's
|
||||||
|
* true peak, and the final clamp only guards against a bad measurement.
|
||||||
|
*
|
||||||
|
* Runs on the playback thread. [prefs] and [store] are read per buffer, so a
|
||||||
|
* changed preference or a gain that arrives mid-track applies at once,
|
||||||
|
* through a short ramp rather than a step.
|
||||||
|
*/
|
||||||
|
class GainAudioProcessor(
|
||||||
|
private val prefs: () -> NormalizationPrefs,
|
||||||
|
private val store: ReplayGainStore,
|
||||||
|
) : BaseAudioProcessor() {
|
||||||
|
|
||||||
|
/** Mirrors the player's shuffle mode, so neighbours are found in play order. */
|
||||||
|
@Volatile var shuffleEnabled: Boolean = false
|
||||||
|
|
||||||
|
private var currentId: String? = null
|
||||||
|
private var asAlbum = false
|
||||||
|
private val stage = GainStage()
|
||||||
|
|
||||||
|
override fun onConfigure(inputAudioFormat: AudioProcessor.AudioFormat): AudioProcessor.AudioFormat =
|
||||||
|
when (inputAudioFormat.encoding) {
|
||||||
|
C.ENCODING_PCM_16BIT, C.ENCODING_PCM_FLOAT -> inputAudioFormat
|
||||||
|
else -> AudioProcessor.AudioFormat.NOT_SET
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFlush(streamMetadata: AudioProcessor.StreamMetadata) {
|
||||||
|
identify(streamMetadata)
|
||||||
|
// A new stream starts at its own level: ramping in from the previous
|
||||||
|
// track's gain would swell or dip its first moments.
|
||||||
|
stage.reset(inputAudioFormat.sampleRate, inputAudioFormat.channelCount, targetGain())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReset() {
|
||||||
|
currentId = null
|
||||||
|
asAlbum = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun queueInput(inputBuffer: ByteBuffer) {
|
||||||
|
val size = inputBuffer.remaining()
|
||||||
|
if (size == 0) return
|
||||||
|
val out = replaceOutputBuffer(size)
|
||||||
|
val p = prefs()
|
||||||
|
val target = targetGain(p)
|
||||||
|
val limiting = p.mode != NormalizationMode.OFF && p.boost == NormalizationBoost.LIMITER
|
||||||
|
if (stage.isUnity(target, limiting)) {
|
||||||
|
out.put(inputBuffer)
|
||||||
|
} else {
|
||||||
|
stage.process(
|
||||||
|
input = inputBuffer,
|
||||||
|
out = out,
|
||||||
|
channels = inputAudioFormat.channelCount,
|
||||||
|
isFloat = inputAudioFormat.encoding == C.ENCODING_PCM_FLOAT,
|
||||||
|
target = target,
|
||||||
|
limiting = limiting,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
out.flip()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun targetGain(p: NormalizationPrefs = prefs()): Float {
|
||||||
|
val id = currentId ?: return 1f
|
||||||
|
return GainMath.dbToLinear(GainMath.gainDb(p, store.get(id), asAlbum))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which track this stream is, and whether it is playing as part of its
|
||||||
|
// album. Also asks the store for the gains of the tracks coming up, so
|
||||||
|
// each is known before it starts.
|
||||||
|
private fun identify(meta: AudioProcessor.StreamMetadata) {
|
||||||
|
currentId = null
|
||||||
|
asAlbum = false
|
||||||
|
val uid = meta.periodUid
|
||||||
|
if (uid == null || meta.timeline.isEmpty) return
|
||||||
|
val timeline = meta.timeline
|
||||||
|
val index = timeline.getPeriodByUid(uid, Timeline.Period()).windowIndex
|
||||||
|
val window = Timeline.Window()
|
||||||
|
fun itemAt(i: Int): MediaItem? =
|
||||||
|
if (i == C.INDEX_UNSET) null else timeline.getWindow(i, window).mediaItem
|
||||||
|
fun nextOf(i: Int) = timeline.getNextWindowIndex(i, Player.REPEAT_MODE_OFF, shuffleEnabled)
|
||||||
|
|
||||||
|
val cur = itemAt(index)
|
||||||
|
val prev = itemAt(timeline.getPreviousWindowIndex(index, Player.REPEAT_MODE_OFF, shuffleEnabled))
|
||||||
|
val next = itemAt(nextOf(index))
|
||||||
|
if (cur != null) {
|
||||||
|
currentId = cur.mediaId
|
||||||
|
asAlbum = GainMath.playingAsAlbum(prev?.albumPosition(), cur.albumPosition(), next?.albumPosition())
|
||||||
|
}
|
||||||
|
|
||||||
|
val upcoming = mutableListOf<String>()
|
||||||
|
var i = index
|
||||||
|
while (i != C.INDEX_UNSET && upcoming.size < LOOKAHEAD) {
|
||||||
|
itemAt(i)?.let { upcoming += it.mediaId }
|
||||||
|
i = nextOf(i)
|
||||||
|
}
|
||||||
|
store.request(upcoming)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** MediaMetadata extras key holding the track's album id. */
|
||||||
|
const val EXTRA_ALBUM_ID = "minstrel.album_id"
|
||||||
|
|
||||||
|
private const val LOOKAHEAD = 20
|
||||||
|
|
||||||
|
/** Puts what [albumPosition] reads into a MediaItem's metadata extras. */
|
||||||
|
fun albumExtras(albumId: String, into: Bundle = Bundle()): Bundle =
|
||||||
|
into.apply { putString(EXTRA_ALBUM_ID, albumId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MediaItem.albumPosition(): AlbumPosition = AlbumPosition(
|
||||||
|
albumId = mediaMetadata.extras?.getString(GainAudioProcessor.EXTRA_ALBUM_ID),
|
||||||
|
discNumber = mediaMetadata.discNumber,
|
||||||
|
trackNumber = mediaMetadata.trackNumber,
|
||||||
|
)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.fabledsword.minstrel.player.gain
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationBoost
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationMode
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
|
import kotlin.math.log10
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.pow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One track's ReplayGain 2.0 values from the server (#4997): dB to the
|
||||||
|
* -18 LUFS reference, and linear true peaks. A null field has not been
|
||||||
|
* measured yet.
|
||||||
|
*/
|
||||||
|
data class ReplayGain(
|
||||||
|
val trackGain: Float?,
|
||||||
|
val trackPeak: Float?,
|
||||||
|
val albumGain: Float?,
|
||||||
|
val albumPeak: Float?,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val NONE = ReplayGain(null, null, null, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a queue item sits in its album, for the auto-mode album rule. */
|
||||||
|
data class AlbumPosition(val albumId: String?, val discNumber: Int?, val trackNumber: Int?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loudness-normalization math (M464 #5000). The same rules as the web
|
||||||
|
* player's `web/src/lib/player/gain.ts`, so a track levels the same on
|
||||||
|
* every device.
|
||||||
|
*/
|
||||||
|
object GainMath {
|
||||||
|
private const val REFERENCE_LUFS = -18
|
||||||
|
|
||||||
|
/** Headroom mode raises a quiet track only until its true peak reaches this. */
|
||||||
|
const val PEAK_CEILING_DBTP = -1f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No track is raised more than this, whatever its measurement says: a
|
||||||
|
* near-silent track would otherwise come out as amplified noise.
|
||||||
|
*/
|
||||||
|
const val MAX_BOOST_DB = 12f
|
||||||
|
|
||||||
|
private const val DISC_STRIDE = 1000
|
||||||
|
|
||||||
|
// Amplitude decibels: 20 dB per factor of ten.
|
||||||
|
private const val DB_PER_DECADE = 20f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gain to apply, in dB. 0 when leveling is off or the track has not
|
||||||
|
* been measured: an unmeasured track plays as mastered.
|
||||||
|
*/
|
||||||
|
fun gainDb(prefs: NormalizationPrefs, g: ReplayGain?, asAlbum: Boolean): Float {
|
||||||
|
if (prefs.mode == NormalizationMode.OFF || g == null) return 0f
|
||||||
|
val wantAlbum = prefs.mode == NormalizationMode.ALBUM ||
|
||||||
|
(prefs.mode == NormalizationMode.AUTO && asAlbum)
|
||||||
|
// Album gain falls back to track gain while the album is still being
|
||||||
|
// measured; track gain never falls back to album gain.
|
||||||
|
val useAlbum = wantAlbum && g.albumGain != null
|
||||||
|
val gain = if (useAlbum) g.albumGain else g.trackGain
|
||||||
|
val peak = if (useAlbum) g.albumPeak else g.trackPeak
|
||||||
|
return gain?.let { leveled(prefs, it, peak) } ?: 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun leveled(prefs: NormalizationPrefs, gain: Float, peak: Float?): Float {
|
||||||
|
var db = gain + (prefs.targetLufs - REFERENCE_LUFS)
|
||||||
|
if (prefs.boost == NormalizationBoost.HEADROOM && peak != null && peak > 0f) {
|
||||||
|
db = min(db, PEAK_CEILING_DBTP - DB_PER_DECADE * log10(peak))
|
||||||
|
}
|
||||||
|
return min(db, MAX_BOOST_DB)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the current item is being played as part of its album, in
|
||||||
|
* order: a neighbour in play order is from the same album and sits on
|
||||||
|
* the right side of it. That is when album gain keeps the album's own
|
||||||
|
* dynamics (a quiet intro stays quiet); anywhere else track gain levels
|
||||||
|
* the mix.
|
||||||
|
*/
|
||||||
|
fun playingAsAlbum(prev: AlbumPosition?, cur: AlbumPosition, next: AlbumPosition?): Boolean {
|
||||||
|
val curOrder = order(cur)
|
||||||
|
if (cur.albumId == null || curOrder == null) return false
|
||||||
|
val prevOrder = prev?.takeIf { it.albumId == cur.albumId }?.let { order(it) }
|
||||||
|
val nextOrder = next?.takeIf { it.albumId == cur.albumId }?.let { order(it) }
|
||||||
|
return (prevOrder != null && prevOrder < curOrder) || (nextOrder != null && nextOrder > curOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disc-major track order. A track with no number has no place in the
|
||||||
|
// order and is never evidence of album play.
|
||||||
|
private fun order(p: AlbumPosition): Int? =
|
||||||
|
p.trackNumber?.let { (p.discNumber ?: 1) * DISC_STRIDE + it }
|
||||||
|
|
||||||
|
fun dbToLinear(db: Float): Float = 10f.pow(db / DB_PER_DECADE)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.fabledsword.minstrel.player.gain
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.exp
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sample arithmetic of [GainAudioProcessor], apart from Media3 so it can
|
||||||
|
* be tested on the JVM: a gain that ramps toward its target, then an
|
||||||
|
* optional peak limiter, then a clamp to full scale. Interleaved 16-bit or
|
||||||
|
* float PCM in, the same format out.
|
||||||
|
*/
|
||||||
|
internal class GainStage {
|
||||||
|
private var gain = 1f
|
||||||
|
private var envelope = 0f
|
||||||
|
private var rampCoeff = 1f
|
||||||
|
private var releaseCoeff = 0f
|
||||||
|
private var frame = FloatArray(2)
|
||||||
|
|
||||||
|
/** Sets the sample rate and channel count, and starts the gain at [startGain]. */
|
||||||
|
fun reset(sampleRate: Int, channels: Int, startGain: Float) {
|
||||||
|
val rate = sampleRate.coerceAtLeast(1).toFloat()
|
||||||
|
rampCoeff = 1f - exp(-1f / (RAMP_SECONDS * rate))
|
||||||
|
releaseCoeff = exp(-1f / (RELEASE_SECONDS * rate))
|
||||||
|
if (frame.size < channels) frame = FloatArray(channels)
|
||||||
|
gain = startGain
|
||||||
|
envelope = 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when processing would copy the input unchanged. */
|
||||||
|
fun isUnity(target: Float, limiting: Boolean): Boolean = gain == 1f && target == 1f && !limiting
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes every whole frame of [input] into [out]. A trailing partial
|
||||||
|
* frame is dropped, as Media3's own processors do.
|
||||||
|
*/
|
||||||
|
@Suppress("LongParameterList") // the PCM layout is four facts; a holder type would only rename them
|
||||||
|
fun process(
|
||||||
|
input: ByteBuffer,
|
||||||
|
out: ByteBuffer,
|
||||||
|
channels: Int,
|
||||||
|
isFloat: Boolean,
|
||||||
|
target: Float,
|
||||||
|
limiting: Boolean,
|
||||||
|
) {
|
||||||
|
val bytesPerFrame = channels * if (isFloat) FLOAT_BYTES else PCM16_BYTES
|
||||||
|
val frames = input.remaining() / bytesPerFrame
|
||||||
|
repeat(frames) {
|
||||||
|
gain += (target - gain) * rampCoeff
|
||||||
|
var peak = 0f
|
||||||
|
for (c in 0 until channels) {
|
||||||
|
val s = (if (isFloat) input.float else input.short / PCM16_SCALE) * gain
|
||||||
|
frame[c] = s
|
||||||
|
peak = max(peak, abs(s))
|
||||||
|
}
|
||||||
|
var reduce = 1f
|
||||||
|
if (limiting) {
|
||||||
|
envelope = max(peak, envelope * releaseCoeff)
|
||||||
|
if (envelope > LIMIT_CEILING) reduce = LIMIT_CEILING / envelope
|
||||||
|
}
|
||||||
|
for (c in 0 until channels) {
|
||||||
|
val v = (frame[c] * reduce).coerceIn(-1f, 1f)
|
||||||
|
if (isFloat) out.putFloat(v) else out.putShort((v * PCM16_MAX).roundToInt().toShort())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.position(input.limit())
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** -1 dBFS, the ceiling the web player's limiter holds too. */
|
||||||
|
const val LIMIT_CEILING = 0.8913f
|
||||||
|
|
||||||
|
private const val RAMP_SECONDS = 0.05f
|
||||||
|
private const val RELEASE_SECONDS = 0.25f
|
||||||
|
private const val PCM16_SCALE = 32768f
|
||||||
|
private const val PCM16_MAX = 32767f
|
||||||
|
private const val PCM16_BYTES = 2
|
||||||
|
private const val FLOAT_BYTES = 4
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package com.fabledsword.minstrel.player.gain
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.ReplayGainApi
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.create
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gains the player levels by (M464 #5000), looked up per track and
|
||||||
|
* held for the life of the process. The audio thread reads them with [get];
|
||||||
|
* [request] fills them in the background.
|
||||||
|
*
|
||||||
|
* Sources, most to least preferred:
|
||||||
|
* 1. the library cache, synced with the gains, so cached audio levels
|
||||||
|
* offline;
|
||||||
|
* 2. the server's replay-gain lookup, for a track not cached yet or
|
||||||
|
* measured since the last sync;
|
||||||
|
* 3. none: the track plays as mastered, and the player picks the gain up
|
||||||
|
* the moment it lands.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ReplayGainStore internal constructor(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val trackDao: CachedTrackDao,
|
||||||
|
private val api: ReplayGainApi,
|
||||||
|
private val serverHealth: () -> ServerHealth,
|
||||||
|
private val clock: () -> Long,
|
||||||
|
) {
|
||||||
|
@Inject constructor(
|
||||||
|
@ApplicationScope scope: CoroutineScope,
|
||||||
|
trackDao: CachedTrackDao,
|
||||||
|
retrofit: Retrofit,
|
||||||
|
network: NetworkStatusController,
|
||||||
|
) : this(
|
||||||
|
scope = scope,
|
||||||
|
trackDao = trackDao,
|
||||||
|
api = retrofit.create(),
|
||||||
|
serverHealth = { network.state.value },
|
||||||
|
clock = System::currentTimeMillis,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val gains = ConcurrentHashMap<String, ReplayGain>()
|
||||||
|
private val inflight: MutableSet<String> = ConcurrentHashMap.newKeySet()
|
||||||
|
|
||||||
|
// When the server last said it had nothing for a track. Not cached for
|
||||||
|
// good: the backfill may measure it minutes later.
|
||||||
|
private val missedAt = ConcurrentHashMap<String, Long>()
|
||||||
|
|
||||||
|
/** The track's gains, or null while they are unknown. Safe from any thread. */
|
||||||
|
fun get(trackId: String): ReplayGain? = gains[trackId]
|
||||||
|
|
||||||
|
/** Starts loading gains for any of [trackIds] not already known or loading. */
|
||||||
|
fun request(trackIds: Collection<String>) {
|
||||||
|
val now = clock()
|
||||||
|
val wanted = trackIds.filter { id ->
|
||||||
|
!gains.containsKey(id) &&
|
||||||
|
(missedAt[id]?.let { now - it > MISS_RETRY_MS } ?: true) &&
|
||||||
|
inflight.add(id)
|
||||||
|
}
|
||||||
|
if (wanted.isEmpty()) return
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
load(wanted)
|
||||||
|
} finally {
|
||||||
|
inflight.removeAll(wanted.toSet())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun load(ids: List<String>) {
|
||||||
|
fromCache(ids)
|
||||||
|
val rest = ids.filter { !gains.containsKey(it) }
|
||||||
|
val health = serverHealth()
|
||||||
|
if (rest.isEmpty() || health == ServerHealth.Offline || health == ServerHealth.ServerDown) return
|
||||||
|
for (batch in rest.chunked(MAX_IDS_PER_REQUEST)) {
|
||||||
|
// A failed lookup leaves the batch unknown; the next request asks again.
|
||||||
|
if (!fromServer(batch)) return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fromCache(ids: List<String>) {
|
||||||
|
val rows = try {
|
||||||
|
trackDao.replayGains(ids)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
Timber.w(e, "replay gain: cache read failed")
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
for (row in rows) {
|
||||||
|
// A cached row without a track gain is not an answer: the server
|
||||||
|
// may have measured the track since this device last synced.
|
||||||
|
if (row.trackGain == null) continue
|
||||||
|
gains[row.id] = ReplayGain(row.trackGain, row.trackPeak, row.albumGain, row.albumPeak)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asks the server about [batch]; false when it could not be asked. */
|
||||||
|
private suspend fun fromServer(batch: List<String>): Boolean {
|
||||||
|
val res = try {
|
||||||
|
withTimeoutOrNull(REQUEST_TIMEOUT_MS) { api.getReplayGain(batch.joinToString(",")) }
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
Timber.w(e, "replay gain: lookup failed")
|
||||||
|
null
|
||||||
|
} ?: return false
|
||||||
|
val now = clock()
|
||||||
|
for (id in batch) {
|
||||||
|
val w = res.items[id]
|
||||||
|
if (w == null) {
|
||||||
|
missedAt[id] = now
|
||||||
|
} else {
|
||||||
|
gains[id] = ReplayGain(w.trackGain, w.trackPeak, w.albumGain, w.albumPeak)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MAX_IDS_PER_REQUEST = 200 // the endpoint's limit
|
||||||
|
const val REQUEST_TIMEOUT_MS = 10_000L
|
||||||
|
const val MISS_RETRY_MS = 5 * 60_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
-22
@@ -4,6 +4,8 @@ import com.fabledsword.minstrel.di.ApplicationScope
|
|||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import com.fabledsword.minstrel.player.RemotePlayerState
|
import com.fabledsword.minstrel.player.RemotePlayerState
|
||||||
import com.fabledsword.minstrel.player.StreamTokenProvider
|
import com.fabledsword.minstrel.player.StreamTokenProvider
|
||||||
|
import com.fabledsword.minstrel.player.gain.AlbumPosition
|
||||||
|
import com.fabledsword.minstrel.player.gain.GainMath
|
||||||
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
|
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
|
||||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||||
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
||||||
@@ -24,6 +26,11 @@ import javax.inject.Singleton
|
|||||||
* with its own failure modes, and it had grown large enough to hide one:
|
* with its own failure modes, and it had grown large enough to hide one:
|
||||||
* every write here is a SOAP call that can fail individually, and until
|
* every write here is a SOAP call that can fail individually, and until
|
||||||
* [verifyQueueLength] nothing ever read the result back.
|
* [verifyQueueLength] nothing ever read the result back.
|
||||||
|
*
|
||||||
|
* Every URL sent is a leveled one (M464 #5002): the server renders the track
|
||||||
|
* at the user's loudness gain, or hands back the plain stream when leveling
|
||||||
|
* is off. Renders are slow enough to matter, so the track playing and the
|
||||||
|
* one after it are rendered ahead; the rest render when the speaker asks.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class SonosQueueLoader @Inject constructor(
|
class SonosQueueLoader @Inject constructor(
|
||||||
@@ -32,6 +39,13 @@ class SonosQueueLoader @Inject constructor(
|
|||||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||||
private val remoteState: RemotePlayerState,
|
private val remoteState: RemotePlayerState,
|
||||||
) {
|
) {
|
||||||
|
// The queue as last sent to the renderer, for looking up the track after
|
||||||
|
// the one it is playing.
|
||||||
|
@Volatile private var sent: List<TrackRef> = emptyList()
|
||||||
|
|
||||||
|
// The renderer track the next one was last prerendered for.
|
||||||
|
@Volatile private var prerenderedAfter = 0
|
||||||
|
|
||||||
suspend fun load(
|
suspend fun load(
|
||||||
transport: AVTransportClient,
|
transport: AVTransportClient,
|
||||||
route: OutputRoute,
|
route: OutputRoute,
|
||||||
@@ -46,8 +60,10 @@ class SonosQueueLoader @Inject constructor(
|
|||||||
"UPnP select: add %d initial tracks (currentIndex=%d, totalQueue=%d)",
|
"UPnP select: add %d initial tracks (currentIndex=%d, totalQueue=%d)",
|
||||||
initialBatch.size, currentIndex, queue.size,
|
initialBatch.size, currentIndex, queue.size,
|
||||||
)
|
)
|
||||||
initialBatch.forEachIndexed { idx, ref ->
|
sent = queue
|
||||||
val token = streamTokens.mint(ref.id)
|
prerenderedAfter = currentIndex + 1
|
||||||
|
initialBatch.indices.forEach { idx ->
|
||||||
|
val token = mint(queue, idx, prerender = idx == currentIndex)
|
||||||
transport.addURIToQueue(
|
transport.addURIToQueue(
|
||||||
uri = token.url,
|
uri = token.url,
|
||||||
mime = token.mime,
|
mime = token.mime,
|
||||||
@@ -64,12 +80,12 @@ class SonosQueueLoader @Inject constructor(
|
|||||||
Timber.w("UPnP select: Play")
|
Timber.w("UPnP select: Play")
|
||||||
transport.play()
|
transport.play()
|
||||||
Timber.w("UPnP select: initial done; backgrounding remainder")
|
Timber.w("UPnP select: initial done; backgrounding remainder")
|
||||||
val remaining = queue.drop(initialEnd)
|
|
||||||
// Verify even when there is no tail to append: the initial batch is
|
// Verify even when there is no tail to append: the initial batch is
|
||||||
// sent the same way and can be dropped the same way.
|
// sent the same way and can be dropped the same way.
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (remaining.isNotEmpty()) {
|
prerender(queue, currentIndex + 1)
|
||||||
extendQueueOnSonos(transport, route, remaining, initialEnd)
|
if (initialEnd < queue.size) {
|
||||||
|
extendQueueOnSonos(transport, route, queue, initialEnd)
|
||||||
}
|
}
|
||||||
verifyQueueLength(transport, route, queue)
|
verifyQueueLength(transport, route, queue)
|
||||||
}
|
}
|
||||||
@@ -84,15 +100,16 @@ class SonosQueueLoader @Inject constructor(
|
|||||||
private suspend fun extendQueueOnSonos(
|
private suspend fun extendQueueOnSonos(
|
||||||
transport: AVTransportClient,
|
transport: AVTransportClient,
|
||||||
route: OutputRoute,
|
route: OutputRoute,
|
||||||
tracks: List<TrackRef>,
|
queue: List<TrackRef>,
|
||||||
startPosition: Int,
|
startPosition: Int,
|
||||||
) {
|
) {
|
||||||
|
val count = queue.size - startPosition
|
||||||
Timber.w(
|
Timber.w(
|
||||||
"UPnP extend: appending %d tracks starting at position %d",
|
"UPnP extend: appending %d tracks starting at position %d",
|
||||||
tracks.size, startPosition + 1,
|
count, startPosition + 1,
|
||||||
)
|
)
|
||||||
val succeeded = appendTracksToQueue(transport, route, tracks, startPosition)
|
val succeeded = appendTracksToQueue(transport, route, queue, startPosition)
|
||||||
Timber.w("UPnP extend: done (%d / %d appended)", succeeded, tracks.size)
|
Timber.w("UPnP extend: done (%d / %d appended)", succeeded, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,18 +153,17 @@ class SonosQueueLoader @Inject constructor(
|
|||||||
Timber.w("UPnP verify: renderer holds %d tracks, queue intact", nrTracks)
|
Timber.w("UPnP verify: renderer holds %d tracks, queue intact", nrTracks)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val missing = fullQueue.drop(nrTracks)
|
|
||||||
Timber.w(
|
Timber.w(
|
||||||
"UPnP verify: %s holds %d of %d tracks; appending %d missing (round %d)",
|
"UPnP verify: %s holds %d of %d tracks; appending %d missing (round %d)",
|
||||||
route.name, nrTracks, fullQueue.size, missing.size, round + 1,
|
route.name, nrTracks, fullQueue.size, fullQueue.size - nrTracks, round + 1,
|
||||||
)
|
)
|
||||||
appendTracksToQueue(transport, route, missing, nrTracks)
|
appendTracksToQueue(transport, route, fullQueue, nrTracks)
|
||||||
}
|
}
|
||||||
Timber.w("UPnP verify: gave up repairing queue length on %s", route.name)
|
Timber.w("UPnP verify: gave up repairing queue length on %s", route.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append [tracks] at [startPosition] (0-based), returning how many landed.
|
* Append [queue] from [startPosition] (0-based) on, returning how many landed.
|
||||||
* Tolerates individual AddURIToQueue failures — log and continue so some
|
* Tolerates individual AddURIToQueue failures — log and continue so some
|
||||||
* tracks loaded is better than zero tracks loaded — and stops early after
|
* tracks loaded is better than zero tracks loaded — and stops early after
|
||||||
* [EXTEND_ABORT_AFTER_FAILURES] consecutive ones.
|
* [EXTEND_ABORT_AFTER_FAILURES] consecutive ones.
|
||||||
@@ -155,20 +171,20 @@ class SonosQueueLoader @Inject constructor(
|
|||||||
private suspend fun appendTracksToQueue(
|
private suspend fun appendTracksToQueue(
|
||||||
transport: AVTransportClient,
|
transport: AVTransportClient,
|
||||||
route: OutputRoute,
|
route: OutputRoute,
|
||||||
tracks: List<TrackRef>,
|
queue: List<TrackRef>,
|
||||||
startPosition: Int,
|
startPosition: Int,
|
||||||
): Int {
|
): Int {
|
||||||
var consecutiveFailures = 0
|
var consecutiveFailures = 0
|
||||||
var succeeded = 0
|
var succeeded = 0
|
||||||
var aborted = false
|
var aborted = false
|
||||||
for ((i, ref) in tracks.withIndex()) {
|
for (i in 0 until queue.size - startPosition) {
|
||||||
if (aborted) break
|
if (aborted) break
|
||||||
if (activeUpnpHolder.active.value?.routeId != route.id) {
|
if (activeUpnpHolder.active.value?.routeId != route.id) {
|
||||||
Timber.w("UPnP extend: cancelled at offset %d (route changed)", i)
|
Timber.w("UPnP extend: cancelled at offset %d (route changed)", i)
|
||||||
aborted = true
|
aborted = true
|
||||||
} else {
|
} else {
|
||||||
val outcome = runCatching {
|
val outcome = runCatching {
|
||||||
val token = streamTokens.mint(ref.id)
|
val token = mint(queue, startPosition + i)
|
||||||
transport.addURIToQueue(
|
transport.addURIToQueue(
|
||||||
uri = token.url,
|
uri = token.url,
|
||||||
mime = token.mime,
|
mime = token.mime,
|
||||||
@@ -220,6 +236,7 @@ class SonosQueueLoader @Inject constructor(
|
|||||||
newQueue: List<TrackRef>,
|
newQueue: List<TrackRef>,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val newIds = newQueue.map { it.id }
|
val newIds = newQueue.map { it.id }
|
||||||
|
sent = newQueue
|
||||||
if (oldIds == newIds) return true
|
if (oldIds == newIds) return true
|
||||||
val prefixLen = commonPrefixLength(oldIds, newIds)
|
val prefixLen = commonPrefixLength(oldIds, newIds)
|
||||||
val suffixLen = commonSuffixLength(
|
val suffixLen = commonSuffixLength(
|
||||||
@@ -271,8 +288,7 @@ class SonosQueueLoader @Inject constructor(
|
|||||||
prefixLen + 1,
|
prefixLen + 1,
|
||||||
)
|
)
|
||||||
for (i in 0 until addedCount) {
|
for (i in 0 until addedCount) {
|
||||||
val ref = newQueue[prefixLen + i]
|
val token = mint(newQueue, prefixLen + i)
|
||||||
val token = streamTokens.mint(ref.id)
|
|
||||||
transport.addURIToQueue(
|
transport.addURIToQueue(
|
||||||
uri = token.url,
|
uri = token.url,
|
||||||
mime = token.mime,
|
mime = token.mime,
|
||||||
@@ -283,6 +299,27 @@ class SonosQueueLoader @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called on every poll with the renderer's 1-based track number. When it
|
||||||
|
* moves, the track after it is rendered ahead, so the speaker's fetch of
|
||||||
|
* it finds the render ready.
|
||||||
|
*/
|
||||||
|
fun onRendererTrack(trackNumber: Int) {
|
||||||
|
if (trackNumber <= 0 || trackNumber == prerenderedAfter) return
|
||||||
|
prerenderedAfter = trackNumber
|
||||||
|
val queue = sent
|
||||||
|
scope.launch { prerender(queue, trackNumber) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun prerender(queue: List<TrackRef>, index: Int) {
|
||||||
|
if (index !in queue.indices) return
|
||||||
|
runCatching { mint(queue, index, prerender = true) }
|
||||||
|
.onFailure { Timber.w(it, "UPnP prerender failed for %s", queue[index].id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun mint(queue: List<TrackRef>, index: Int, prerender: Boolean = false) =
|
||||||
|
streamTokens.mint(queue[index].id, asAlbum = playingAsAlbum(queue, index), prerender = prerender)
|
||||||
|
|
||||||
private fun commonPrefixLength(a: List<String>, b: List<String>): Int {
|
private fun commonPrefixLength(a: List<String>, b: List<String>): Int {
|
||||||
val limit = minOf(a.size, b.size)
|
val limit = minOf(a.size, b.size)
|
||||||
for (i in 0 until limit) {
|
for (i in 0 until limit) {
|
||||||
@@ -299,18 +336,33 @@ class SonosQueueLoader @Inject constructor(
|
|||||||
return limit
|
return limit
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
companion object {
|
||||||
|
/**
|
||||||
|
* Whether [queue]'s track at [index] plays among its album in order,
|
||||||
|
* judged by its neighbours in the renderer's queue, which plays
|
||||||
|
* straight through.
|
||||||
|
*/
|
||||||
|
internal fun playingAsAlbum(queue: List<TrackRef>, index: Int): Boolean =
|
||||||
|
GainMath.playingAsAlbum(
|
||||||
|
prev = queue.getOrNull(index - 1)?.let(::position),
|
||||||
|
cur = position(queue[index]),
|
||||||
|
next = queue.getOrNull(index + 1)?.let(::position),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun position(t: TrackRef) =
|
||||||
|
AlbumPosition(t.albumId.ifEmpty { null }, t.discNumber, t.trackNumber)
|
||||||
|
|
||||||
// Abort the append loop after this many consecutive AddURIToQueue
|
// Abort the append loop after this many consecutive AddURIToQueue
|
||||||
// failures; Sonos rate-limits burst adds and a wall of failures means
|
// failures; Sonos rate-limits burst adds and a wall of failures means
|
||||||
// it has stopped accepting, not that the next one might land.
|
// it has stopped accepting, not that the next one might land.
|
||||||
const val EXTEND_ABORT_AFTER_FAILURES = 3
|
private const val EXTEND_ABORT_AFTER_FAILURES = 3
|
||||||
const val EXTEND_THROTTLE_MS = 50L
|
private const val EXTEND_THROTTLE_MS = 50L
|
||||||
|
|
||||||
// Verify/repair passes after a queue load. Two: one to catch the
|
// Verify/repair passes after a queue load. Two: one to catch the
|
||||||
// common case (a rate-limit burst dropped a chunk), one to catch a
|
// common case (a rate-limit burst dropped a chunk), one to catch a
|
||||||
// repair that itself got rate-limited. Beyond that the renderer is
|
// repair that itself got rate-limited. Beyond that the renderer is
|
||||||
// refusing for a reason retrying won't fix, and the stall watchdog
|
// refusing for a reason retrying won't fix, and the stall watchdog
|
||||||
// becomes the backstop.
|
// becomes the backstop.
|
||||||
const val VERIFY_ROUNDS = 2
|
private const val VERIFY_ROUNDS = 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package com.fabledsword.minstrel.settings.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's loudness-normalization preference (M464 #4998). The server
|
||||||
|
* holds it, so this device, the web player and casts all level the same
|
||||||
|
* way; a copy is kept on the device so offline playback still applies it.
|
||||||
|
* Field names and values match `GET/PUT /api/me/normalization`.
|
||||||
|
*
|
||||||
|
* No constructor defaults, on purpose: the app's Json leaves out any field
|
||||||
|
* equal to its default (encodeDefaults is off), and the server refuses a PUT
|
||||||
|
* missing a field. Without defaults every field is always sent.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class NormalizationPrefs(
|
||||||
|
val mode: NormalizationMode,
|
||||||
|
@SerialName("target_lufs") val targetLufs: Int,
|
||||||
|
val boost: NormalizationBoost,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
/** The targets the server accepts, quietest first. */
|
||||||
|
val TARGETS: List<Int> = listOf(-18, -16, -14)
|
||||||
|
|
||||||
|
/** Must match library.DefaultNormalizationPrefs on the server. */
|
||||||
|
val DEFAULT: NormalizationPrefs = NormalizationPrefs(
|
||||||
|
mode = NormalizationMode.AUTO,
|
||||||
|
targetLufs = -18,
|
||||||
|
boost = NormalizationBoost.HEADROOM,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class NormalizationMode {
|
||||||
|
/** Tracks play at their mastered volume. */
|
||||||
|
@SerialName("off") OFF,
|
||||||
|
|
||||||
|
/** Album gain while an album plays in order, track gain otherwise. */
|
||||||
|
@SerialName("auto") AUTO,
|
||||||
|
|
||||||
|
@SerialName("track") TRACK,
|
||||||
|
|
||||||
|
@SerialName("album") ALBUM,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class NormalizationBoost {
|
||||||
|
/** Quiet tracks are raised only as far as their true peak allows. */
|
||||||
|
@SerialName("headroom") HEADROOM,
|
||||||
|
|
||||||
|
/** Quiet tracks are raised all the way and a limiter holds the peaks. */
|
||||||
|
@SerialName("limiter") LIMITER,
|
||||||
|
}
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
package com.fabledsword.minstrel.settings.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.MeApi
|
||||||
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.MutationKind
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.create
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's loudness-normalization preference (M464 #4998). The device's
|
||||||
|
* copy lives in [AuthStore] so playback reads it offline; the server's copy
|
||||||
|
* is what other devices and casts use.
|
||||||
|
*
|
||||||
|
* Writes follow rule 100: the device shows the change at once, the PUT is
|
||||||
|
* best effort, and a failed PUT is queued for the MutationReplayer.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class NormalizationRepository @Inject constructor(
|
||||||
|
retrofit: Retrofit,
|
||||||
|
private val authStore: AuthStore,
|
||||||
|
private val mutationQueue: MutationQueue,
|
||||||
|
private val mutationDao: CachedMutationDao,
|
||||||
|
) {
|
||||||
|
private val api: MeApi = retrofit.create()
|
||||||
|
|
||||||
|
val prefs: StateFlow<NormalizationPrefs> = authStore.normalization
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes the server's value, unless a change made here is still queued:
|
||||||
|
* the server has not seen it yet, and taking its older value would undo
|
||||||
|
* the change on screen until the replay landed. Throws on a network
|
||||||
|
* failure; the device's copy then stands.
|
||||||
|
*/
|
||||||
|
suspend fun refresh() {
|
||||||
|
val server = api.getNormalization()
|
||||||
|
if (mutationDao.hasPending(MutationKind.NORMALIZATION_SET)) return
|
||||||
|
authStore.setNormalization(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun set(next: NormalizationPrefs) {
|
||||||
|
authStore.setNormalization(next)
|
||||||
|
// An earlier change still queued would be replayed after this PUT
|
||||||
|
// and undo it. Queue behind it instead: the replayer sends only the
|
||||||
|
// newest queued preference.
|
||||||
|
if (mutationDao.hasPending(MutationKind.NORMALIZATION_SET)) {
|
||||||
|
mutationQueue.enqueueNormalizationSet(next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
api.putNormalization(next)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
Timber.i(e, "normalization: PUT failed; queued for replay")
|
||||||
|
mutationQueue.enqueueNormalizationSet(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-1
@@ -41,6 +41,10 @@ data class AboutUiState(
|
|||||||
// check must decide on. Held in state rather than read inline so a test
|
// check must decide on. Held in state rather than read inline so a test
|
||||||
// can drive the comparison without a BuildConfig.
|
// can drive the comparison without a BuildConfig.
|
||||||
val installedCode: Long = BuildConfig.VERSION_CODE.toLong(),
|
val installedCode: Long = BuildConfig.VERSION_CODE.toLong(),
|
||||||
|
// A debug build is signed with this machine's debug key, so the server's
|
||||||
|
// release-signed APK can never install over it (family idea #5103,
|
||||||
|
// practice 9). It updates from Android Studio instead.
|
||||||
|
val selfUpdates: Boolean = !BuildConfig.DEBUG,
|
||||||
val isChecking: Boolean = false,
|
val isChecking: Boolean = false,
|
||||||
val installStage: InstallStage = InstallStage.IDLE,
|
val installStage: InstallStage = InstallStage.IDLE,
|
||||||
val installMessage: String? = null,
|
val installMessage: String? = null,
|
||||||
@@ -67,7 +71,7 @@ class AboutCardViewModel @Inject constructor(
|
|||||||
val state: StateFlow<AboutUiState> = internal.asStateFlow()
|
val state: StateFlow<AboutUiState> = internal.asStateFlow()
|
||||||
|
|
||||||
fun checkForUpdates() {
|
fun checkForUpdates() {
|
||||||
if (internal.value.isChecking) return
|
if (internal.value.isChecking || !internal.value.selfUpdates) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
internal.update { it.copy(isChecking = true, installMessage = null) }
|
internal.update { it.copy(isChecking = true, installMessage = null) }
|
||||||
val installed = internal.value.installedVersion
|
val installed = internal.value.installedVersion
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package com.fabledsword.minstrel.settings.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SegmentedButton
|
||||||
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationBoost
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationMode
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Volume leveling (M464 #4998). The choice is stored on the server, so the
|
||||||
|
* web player and casts follow it too.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun NormalizationCard(viewModel: NormalizationViewModel = hiltViewModel()) {
|
||||||
|
val prefs by viewModel.prefs.collectAsStateWithLifecycle()
|
||||||
|
NormalizationCardContent(prefs = prefs, onChange = viewModel::update)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun NormalizationCardContent(
|
||||||
|
prefs: NormalizationPrefs,
|
||||||
|
onChange: (NormalizationPrefs) -> Unit,
|
||||||
|
) {
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Volume leveling",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Hint(modeHint(prefs.mode))
|
||||||
|
val modes = NormalizationMode.entries
|
||||||
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
modes.forEachIndexed { index, mode ->
|
||||||
|
SegmentedButton(
|
||||||
|
selected = mode == prefs.mode,
|
||||||
|
onClick = { onChange(prefs.copy(mode = mode)) },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(
|
||||||
|
index = index,
|
||||||
|
count = modes.size,
|
||||||
|
),
|
||||||
|
) { Text(modeLabel(mode)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (prefs.mode != NormalizationMode.OFF) {
|
||||||
|
TargetRow(prefs = prefs, onChange = onChange)
|
||||||
|
BoostRow(prefs = prefs, onChange = onChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun TargetRow(prefs: NormalizationPrefs, onChange: (NormalizationPrefs) -> Unit) {
|
||||||
|
Text(
|
||||||
|
text = "Target loudness",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
val targets = NormalizationPrefs.TARGETS
|
||||||
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
targets.forEachIndexed { index, lufs ->
|
||||||
|
SegmentedButton(
|
||||||
|
selected = lufs == prefs.targetLufs,
|
||||||
|
onClick = { onChange(prefs.copy(targetLufs = lufs)) },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(
|
||||||
|
index = index,
|
||||||
|
count = targets.size,
|
||||||
|
),
|
||||||
|
) { Text("$lufs LUFS") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BoostRow(prefs: NormalizationPrefs, onChange: (NormalizationPrefs) -> Unit) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = "Boost quiet tracks fully",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Hint("A limiter catches the peaks.")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(12.dp))
|
||||||
|
Switch(
|
||||||
|
checked = prefs.boost == NormalizationBoost.LIMITER,
|
||||||
|
onCheckedChange = { on ->
|
||||||
|
val boost = if (on) NormalizationBoost.LIMITER else NormalizationBoost.HEADROOM
|
||||||
|
onChange(prefs.copy(boost = boost))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Hint(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun modeLabel(mode: NormalizationMode): String = when (mode) {
|
||||||
|
NormalizationMode.OFF -> "Off"
|
||||||
|
NormalizationMode.AUTO -> "Auto"
|
||||||
|
NormalizationMode.TRACK -> "Track"
|
||||||
|
NormalizationMode.ALBUM -> "Album"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun modeHint(mode: NormalizationMode): String = when (mode) {
|
||||||
|
NormalizationMode.OFF -> "Tracks play at their mastered volume."
|
||||||
|
NormalizationMode.AUTO -> "Album gain for whole albums, track gain otherwise."
|
||||||
|
NormalizationMode.TRACK -> "Every track at the same loudness."
|
||||||
|
NormalizationMode.ALBUM -> "Albums keep their own quiet and loud tracks."
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
package com.fabledsword.minstrel.settings.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backs the Volume leveling card. Shows the device's copy straight away and
|
||||||
|
* refreshes it from the server on open, so a change made on the web shows
|
||||||
|
* here too.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class NormalizationViewModel @Inject constructor(
|
||||||
|
private val repository: NormalizationRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val prefs: StateFlow<NormalizationPrefs> = repository.prefs
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.refresh()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
// Offline: the device's copy stands.
|
||||||
|
Timber.i(e, "normalization: refresh failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(next: NormalizationPrefs) {
|
||||||
|
viewModelScope.launch { repository.set(next) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@ class PasswordViewModel @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
repository.changePassword(current = s.current, next = s.next)
|
repository.changePassword(current = s.current, next = s.next)
|
||||||
internal.update {
|
internal.update {
|
||||||
PasswordUiState(message = "Password changed.")
|
PasswordUiState(message = "Password changed. Your other devices have been signed out.")
|
||||||
}
|
}
|
||||||
} catch (
|
} catch (
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ private fun SettingsList(
|
|||||||
onToggle = onToggleDiagnostics,
|
onToggle = onToggleDiagnostics,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
NormalizationCard()
|
||||||
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
|
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
|
||||||
StorageCard()
|
StorageCard()
|
||||||
AboutCard()
|
AboutCard()
|
||||||
@@ -382,6 +383,14 @@ private fun AboutCard(viewModel: AboutCardViewModel = hiltViewModel()) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
||||||
|
if (!state.selfUpdates) {
|
||||||
|
Text(
|
||||||
|
text = "Debug build: updates install from Android Studio.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
UpdateCheckLine(result = state.result)
|
UpdateCheckLine(result = state.result)
|
||||||
Button(
|
Button(
|
||||||
onClick = viewModel::checkForUpdates,
|
onClick = viewModel::checkForUpdates,
|
||||||
|
|||||||
+7
@@ -29,6 +29,9 @@ private const val POLL_INTERVAL_MS = 24 * 60 * 60 * 1000L
|
|||||||
* restart re-shows it, which is acceptable nudging for v1 (matches
|
* restart re-shows it, which is acceptable nudging for v1 (matches
|
||||||
* Flutter). Server 404 / network errors stay silent. Constructed at
|
* Flutter). Server 404 / network errors stay silent. Constructed at
|
||||||
* launch via the construct-the-singleton trick in `MinstrelApplication`.
|
* launch via the construct-the-singleton trick in `MinstrelApplication`.
|
||||||
|
*
|
||||||
|
* A debug build never polls: it is signed with a local debug key, so the
|
||||||
|
* server's release-signed APK could never install over it (#5103).
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class UpdateBannerController @Inject constructor(
|
class UpdateBannerController @Inject constructor(
|
||||||
@@ -45,6 +48,10 @@ class UpdateBannerController @Inject constructor(
|
|||||||
}.stateIn(scope, SharingStarted.Eagerly, null)
|
}.stateIn(scope, SharingStarted.Eagerly, null)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
if (!BuildConfig.DEBUG) startPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPolling() {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
while (true) {
|
while (true) {
|
||||||
runOnce()
|
runOnce()
|
||||||
|
|||||||
@@ -17,8 +17,12 @@
|
|||||||
hostnames rather than CIDR ranges, and both sets of hosts above are unknowable
|
hostnames rather than CIDR ranges, and both sets of hosts above are unknowable
|
||||||
until runtime. So a permissive base-config is an honest description of our
|
until runtime. So a permissive base-config is an honest description of our
|
||||||
situation — the gain over the manifest attribute is that the reasoning now
|
situation — the gain over the manifest attribute is that the reasoning now
|
||||||
lives somewhere, and there is one place to tighten if a future settings screen
|
lives somewhere.
|
||||||
can distinguish a LAN server from a WAN one.
|
|
||||||
|
The LAN/WAN line this file cannot draw is drawn in code instead:
|
||||||
|
api/CleartextGuard.kt refuses plain http:// to the Minstrel server whenever
|
||||||
|
the connection lands on a public address (family baseline #5105, practice
|
||||||
|
13). LAN servers and UPnP speakers are unaffected.
|
||||||
|
|
||||||
Worth stating because it looks worse than it is: this is NOT a tamper risk for
|
Worth stating because it looks worse than it is: this is NOT a tamper risk for
|
||||||
the in-app updater. An APK altered in transit and re-signed is rejected by the
|
the in-app updater. An APK altered in transit and re-signed is rejected by the
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.fabledsword.minstrel.api
|
package com.fabledsword.minstrel.api
|
||||||
|
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
|
import com.fabledsword.minstrel.auth.FakeSessionVault
|
||||||
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
@@ -43,7 +44,7 @@ class AuthCookieInterceptorTest {
|
|||||||
coEvery { setSessionCookie(any()) } returns Unit
|
coEvery { setSessionCookie(any()) } returns Unit
|
||||||
coEvery { setBaseUrl(any()) } returns Unit
|
coEvery { setBaseUrl(any()) } returns Unit
|
||||||
}
|
}
|
||||||
authStore = AuthStore(dao, TestScope(UnconfinedTestDispatcher()))
|
authStore = AuthStore(dao, FakeSessionVault(), TestScope(UnconfinedTestDispatcher()))
|
||||||
|
|
||||||
// BaseUrlInterceptor rewrites placeholder.invalid → mock server.
|
// BaseUrlInterceptor rewrites placeholder.invalid → mock server.
|
||||||
// AuthCookieInterceptor scopes its attach + clear behavior to
|
// AuthCookieInterceptor scopes its attach + clear behavior to
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.fabledsword.minstrel.api
|
package com.fabledsword.minstrel.api
|
||||||
|
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
|
import com.fabledsword.minstrel.auth.FakeSessionVault
|
||||||
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
@@ -38,7 +39,7 @@ class BaseUrlInterceptorTest {
|
|||||||
coEvery { setSessionCookie(any()) } returns Unit
|
coEvery { setSessionCookie(any()) } returns Unit
|
||||||
coEvery { setBaseUrl(any()) } returns Unit
|
coEvery { setBaseUrl(any()) } returns Unit
|
||||||
}
|
}
|
||||||
authStore = AuthStore(dao, TestScope(UnconfinedTestDispatcher()))
|
authStore = AuthStore(dao, FakeSessionVault(), TestScope(UnconfinedTestDispatcher()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.fabledsword.minstrel.api
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.assertThrows
|
||||||
|
import java.net.InetAddress
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/** Plain http:// to the Minstrel server only on a private address (#5105, practice 13). */
|
||||||
|
class CleartextGuardTest {
|
||||||
|
private lateinit var server: MockWebServer
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setup() {
|
||||||
|
server = MockWebServer().apply { start() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun teardown() {
|
||||||
|
server.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ip(s: String) = InetAddress.getByName(s)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `home network and overlay addresses may use plain http`() {
|
||||||
|
for (a in listOf(
|
||||||
|
"127.0.0.1", "10.1.2.3", "172.16.0.9", "172.31.255.1", "192.168.1.20",
|
||||||
|
"169.254.3.4", "100.64.0.1", "100.127.255.254", "::1", "fd12:3456::1", "fe80::1",
|
||||||
|
)) {
|
||||||
|
assertTrue(CleartextPolicy.allows(ip(a)), "$a should be allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `public addresses may not`() {
|
||||||
|
for (a in listOf(
|
||||||
|
"8.8.8.8", "172.32.0.1", "100.128.0.1", "100.63.255.255", "203.0.113.5",
|
||||||
|
"2001:db8::1", "2606:4700::1111",
|
||||||
|
)) {
|
||||||
|
assertFalse(CleartextPolicy.allows(ip(a)), "$a should be refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun client(allows: Boolean) = OkHttpClient.Builder()
|
||||||
|
.addNetworkInterceptor(CleartextGuardInterceptor { allows })
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun serverRequest() = Request.Builder()
|
||||||
|
.url(server.url("/api/auth/login"))
|
||||||
|
.tag(MinstrelServerRequest::class.java, MinstrelServerRequest)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a refused server request sends nothing`() {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(200))
|
||||||
|
assertThrows<CleartextToPublicHostException> {
|
||||||
|
client(allows = false).newCall(serverRequest()).execute()
|
||||||
|
}
|
||||||
|
assertEquals(0, server.requestCount, "the request reached the server")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an allowed server request goes through`() {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(200))
|
||||||
|
client(allows = true).newCall(serverRequest()).execute().use { assertEquals(200, it.code) }
|
||||||
|
assertEquals(1, server.requestCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `requests not bound for the Minstrel server are left alone`() {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(200))
|
||||||
|
client(allows = false).newCall(Request.Builder().url(server.url("/art.jpg")).build())
|
||||||
|
.execute().use { assertEquals(200, it.code) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the refusal has its own message`() {
|
||||||
|
val msg = ErrorCopy.fromThrowable(CleartextToPublicHostException("music.example.com"))
|
||||||
|
assertTrue(msg.contains("https://"), msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.fabledsword.minstrel.api
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import retrofit2.Response
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class ErrorCopyTest {
|
||||||
|
private fun httpError(status: Int, body: String): HttpException =
|
||||||
|
HttpException(
|
||||||
|
Response.error<Unit>(status, body.toResponseBody("application/json".toMediaType())),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun libraryNotWritableAppendsTheServerDetail() {
|
||||||
|
val detail = "Minstrel runs as uid 1000, gid 1000 and cannot delete from /music/A " +
|
||||||
|
"(read-only file system). The library mount must be writable by that user. " +
|
||||||
|
"Nothing was deleted."
|
||||||
|
val e = httpError(409, """{"error":{"code":"library_not_writable","message":"$detail"}}""")
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"${ErrorCopy.messageFor("library_not_writable")} $detail",
|
||||||
|
ErrorCopy.fromThrowable(e),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun detailCodeWithoutAMessageShowsTheCopyAlone() {
|
||||||
|
val e = httpError(409, """{"error":{"code":"library_not_writable","message":""}}""")
|
||||||
|
|
||||||
|
assertEquals(ErrorCopy.messageFor("library_not_writable"), ErrorCopy.fromThrowable(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server messages are usually internal detail; appending them for every
|
||||||
|
// code would leak driver errors into snackbars. This pins the scope.
|
||||||
|
@Test
|
||||||
|
fun otherCodesNeverCarryTheServerMessage() {
|
||||||
|
val e = httpError(404, """{"error":{"code":"track_not_found","message":"pgx: no rows"}}""")
|
||||||
|
|
||||||
|
assertEquals(ErrorCopy.messageFor("track_not_found"), ErrorCopy.fromThrowable(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anUnparseableBodyFallsBackToUnknown() {
|
||||||
|
val e = httpError(500, "not json")
|
||||||
|
|
||||||
|
assertEquals(ErrorCopy.messageFor("unknown"), ErrorCopy.fromThrowable(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun transportFailureMapsToConnectionRefused() {
|
||||||
|
assertEquals(
|
||||||
|
ErrorCopy.messageFor("connection_refused"),
|
||||||
|
ErrorCopy.fromThrowable(IOException("refused")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.fabledsword.minstrel.auth
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The session cookie moved out of the Room row into a Keystore-backed vault
|
||||||
|
* (M462 #4985). What matters is that nobody is signed out by it: an install
|
||||||
|
* upgrading with a cookie in the row keeps it, a device whose Keystore will
|
||||||
|
* not work keeps the old storage, and a sign-in or 401 that lands while the
|
||||||
|
* one-time move runs is never overwritten by the value it read.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class AuthStoreSessionVaultTest {
|
||||||
|
/** A DAO whose single row holds [legacyCookie] in its sessionCookie column. */
|
||||||
|
private class RowDao(var legacyCookie: String?) {
|
||||||
|
val dao: AuthSessionDao = mockk {
|
||||||
|
every { observe() } returns flowOf(null)
|
||||||
|
coEvery { get() } answers {
|
||||||
|
AuthSessionEntity(baseUrl = "http://music.local", sessionCookie = legacyCookie)
|
||||||
|
}
|
||||||
|
coEvery { upsert(any()) } answers { legacyCookie = firstArg<AuthSessionEntity>().sessionCookie }
|
||||||
|
coEvery { setSessionCookie(any()) } answers { legacyCookie = firstArg() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an upgrading install keeps its session, moved out of the row into the vault`() = runTest {
|
||||||
|
val row = RowDao(legacyCookie = "session=abc")
|
||||||
|
val vault = FakeSessionVault()
|
||||||
|
|
||||||
|
val store = AuthStore(row.dao, vault, TestScope(UnconfinedTestDispatcher(testScheduler)))
|
||||||
|
store.awaitSessionHydrated()
|
||||||
|
|
||||||
|
assertEquals("session=abc", store.sessionCookie.value)
|
||||||
|
assertEquals("session=abc", vault.stored)
|
||||||
|
assertNull(row.legacyCookie, "the plain-text copy must be cleared from the row")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a cookie already in the vault is loaded without touching the row`() = runTest {
|
||||||
|
val row = RowDao(legacyCookie = null)
|
||||||
|
val vault = FakeSessionVault(stored = "session=xyz")
|
||||||
|
|
||||||
|
val store = AuthStore(row.dao, vault, TestScope(UnconfinedTestDispatcher(testScheduler)))
|
||||||
|
store.awaitSessionHydrated()
|
||||||
|
|
||||||
|
assertEquals("session=xyz", store.sessionCookie.value)
|
||||||
|
assertEquals(0, vault.writes)
|
||||||
|
coVerify(exactly = 0) { row.dao.setSessionCookie(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when the Keystore will not work the cookie stays in the row instead of being lost`() = runTest {
|
||||||
|
val row = RowDao(legacyCookie = "session=abc")
|
||||||
|
val vault = FakeSessionVault(available = false)
|
||||||
|
|
||||||
|
val store = AuthStore(row.dao, vault, TestScope(UnconfinedTestDispatcher(testScheduler)))
|
||||||
|
store.awaitSessionHydrated()
|
||||||
|
assertEquals("session=abc", store.sessionCookie.value)
|
||||||
|
assertEquals("session=abc", row.legacyCookie)
|
||||||
|
|
||||||
|
store.setSessionCookie("session=new")
|
||||||
|
assertEquals("session=new", row.legacyCookie, "fallback writes go to the row")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `sign-in and sign-out write the vault, and never the row`() = runTest {
|
||||||
|
val row = RowDao(legacyCookie = null)
|
||||||
|
val vault = FakeSessionVault()
|
||||||
|
val store = AuthStore(row.dao, vault, TestScope(UnconfinedTestDispatcher(testScheduler)))
|
||||||
|
store.awaitSessionHydrated()
|
||||||
|
|
||||||
|
store.setSessionCookie("session=new")
|
||||||
|
assertEquals("session=new", vault.stored)
|
||||||
|
assertNull(row.legacyCookie)
|
||||||
|
|
||||||
|
store.setSessionCookie(null)
|
||||||
|
assertNull(vault.stored)
|
||||||
|
assertNull(store.sessionCookie.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a sign-out that lands before hydration finishes is not undone by it`() = runTest {
|
||||||
|
val row = RowDao(legacyCookie = "session=stale")
|
||||||
|
val vault = FakeSessionVault()
|
||||||
|
val scope = TestScope(StandardTestDispatcher(testScheduler))
|
||||||
|
|
||||||
|
// Hydration is queued but has not run; a 401 clears the session first.
|
||||||
|
val store = AuthStore(row.dao, vault, scope)
|
||||||
|
store.setSessionCookie(null)
|
||||||
|
scope.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertNull(store.sessionCookie.value, "hydration must not resurrect the stale cookie")
|
||||||
|
assertNull(vault.stored)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.fabledsword.minstrel.auth
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory [SessionVault] for JVM tests: the Android Keystore has no JVM
|
||||||
|
* implementation. [available] = false stands in for a device whose Keystore
|
||||||
|
* refuses to work, so callers' fallback paths can be exercised.
|
||||||
|
*/
|
||||||
|
class FakeSessionVault(
|
||||||
|
var stored: String? = null,
|
||||||
|
var available: Boolean = true,
|
||||||
|
) : SessionVault {
|
||||||
|
var writes = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun read(): String? = if (available) stored else null
|
||||||
|
|
||||||
|
override fun write(value: String?): Boolean {
|
||||||
|
if (!available) return false
|
||||||
|
writes++
|
||||||
|
stored = value
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.fabledsword.minstrel.auth
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.assertThrows
|
||||||
|
import java.util.Base64
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNotEquals
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sealing half of the session vault, with an ordinary JVM AES key in
|
||||||
|
* place of the Keystore one (the Keystore has no JVM implementation).
|
||||||
|
*/
|
||||||
|
class SealedBoxTest {
|
||||||
|
private fun newKey(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `round-trips a cookie`() {
|
||||||
|
val key = newKey()
|
||||||
|
assertEquals("session=abc", SealedBox.open(key, SealedBox.seal(key, "session=abc")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the stored form does not contain the cookie, and differs every time`() {
|
||||||
|
val key = newKey()
|
||||||
|
val first = SealedBox.seal(key, "session=abc")
|
||||||
|
val second = SealedBox.seal(key, "session=abc")
|
||||||
|
assertNotEquals(first, second, "a fresh IV per seal")
|
||||||
|
val decoded = String(Base64.getDecoder().decode(first), Charsets.ISO_8859_1)
|
||||||
|
assertFalse(decoded.contains("session=abc"), "plaintext visible in the sealed value")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a tampered value does not open`() {
|
||||||
|
val key = newKey()
|
||||||
|
val bytes = Base64.getDecoder().decode(SealedBox.seal(key, "session=abc"))
|
||||||
|
bytes[bytes.size - 1] = (bytes[bytes.size - 1].toInt() xor 1).toByte()
|
||||||
|
assertThrows<Exception> { SealedBox.open(key, Base64.getEncoder().encodeToString(bytes)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a value sealed under another key does not open`() {
|
||||||
|
val sealed = SealedBox.seal(newKey(), "session=abc")
|
||||||
|
assertThrows<Exception> { SealedBox.open(newKey(), sealed) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+24
@@ -1,6 +1,8 @@
|
|||||||
package com.fabledsword.minstrel.cache.mutations
|
package com.fabledsword.minstrel.cache.mutations
|
||||||
|
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationMode
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
@@ -129,4 +131,26 @@ class SupersededToggleIdsTest {
|
|||||||
fun `an empty queue collapses nothing`() {
|
fun `an empty queue collapses nothing`() {
|
||||||
assertTrue(supersededToggleIds(emptyList(), json).isEmpty())
|
assertTrue(supersededToggleIds(emptyList(), json).isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun normalizationRow(id: Long, mode: NormalizationMode) = CachedMutationEntity(
|
||||||
|
id = id,
|
||||||
|
kind = MutationKind.NORMALIZATION_SET,
|
||||||
|
payload = json.encodeToString(
|
||||||
|
NormalizationPrefs.serializer(),
|
||||||
|
NormalizationPrefs.DEFAULT.copy(mode = mode),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// There is one preference per user, so any two queued changes to it
|
||||||
|
// collapse, and only the newest is sent (#4998).
|
||||||
|
@Test
|
||||||
|
fun `only the newest queued normalization change survives`() {
|
||||||
|
val rows = listOf(
|
||||||
|
normalizationRow(1, NormalizationMode.TRACK),
|
||||||
|
snoozeRow(2, "mb-a", desiredSnoozed = true),
|
||||||
|
normalizationRow(3, NormalizationMode.OFF),
|
||||||
|
normalizationRow(4, NormalizationMode.ALBUM),
|
||||||
|
)
|
||||||
|
assertEquals(setOf(1L, 3L), supersededToggleIds(rows, json))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.fabledsword.minstrel.player.gain
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationBoost
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationMode
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.math.log10
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/** Mirrors web/src/lib/player/gain.test.ts: both players must level alike. */
|
||||||
|
class GainMathTest {
|
||||||
|
|
||||||
|
private fun prefs(
|
||||||
|
mode: NormalizationMode = NormalizationMode.AUTO,
|
||||||
|
target: Int = -18,
|
||||||
|
boost: NormalizationBoost = NormalizationBoost.HEADROOM,
|
||||||
|
) = NormalizationPrefs(mode = mode, targetLufs = target, boost = boost)
|
||||||
|
|
||||||
|
private val g = ReplayGain(trackGain = -6f, trackPeak = 1f, albumGain = -4f, albumPeak = 1f)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `off and unmeasured tracks play as mastered`() {
|
||||||
|
assertEquals(0f, GainMath.gainDb(prefs(mode = NormalizationMode.OFF), g, false))
|
||||||
|
assertEquals(0f, GainMath.gainDb(prefs(), null, false))
|
||||||
|
assertEquals(0f, GainMath.gainDb(prefs(), ReplayGain.NONE, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `mode picks the gain and auto follows album play`() {
|
||||||
|
assertEquals(-6f, GainMath.gainDb(prefs(mode = NormalizationMode.TRACK), g, true))
|
||||||
|
assertEquals(-4f, GainMath.gainDb(prefs(mode = NormalizationMode.ALBUM), g, false))
|
||||||
|
assertEquals(-4f, GainMath.gainDb(prefs(), g, true))
|
||||||
|
assertEquals(-6f, GainMath.gainDb(prefs(), g, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `album gain falls back to track gain until the album is measured`() {
|
||||||
|
val partial = ReplayGain(trackGain = -6f, trackPeak = 1f, albumGain = null, albumPeak = null)
|
||||||
|
assertEquals(-6f, GainMath.gainDb(prefs(mode = NormalizationMode.ALBUM), partial, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a louder target raises every gain by the difference`() {
|
||||||
|
assertEquals(-2f, GainMath.gainDb(prefs(mode = NormalizationMode.TRACK, target = -14), g, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `headroom stops a boost 1 dB under the true peak and the limiter lets it through`() {
|
||||||
|
val quiet = ReplayGain(trackGain = 8f, trackPeak = 0.5f, albumGain = null, albumPeak = null)
|
||||||
|
val headroom = GainMath.gainDb(prefs(mode = NormalizationMode.TRACK), quiet, false)
|
||||||
|
assertEquals(-1f - 20f * log10(0.5f), headroom, 1e-4f)
|
||||||
|
val limited = GainMath.gainDb(
|
||||||
|
prefs(mode = NormalizationMode.TRACK, boost = NormalizationBoost.LIMITER),
|
||||||
|
quiet,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
assertEquals(8f, limited)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no boost passes the cap`() {
|
||||||
|
val silent = ReplayGain(trackGain = 30f, trackPeak = 0.001f, albumGain = null, albumPeak = null)
|
||||||
|
val db = GainMath.gainDb(
|
||||||
|
prefs(mode = NormalizationMode.TRACK, boost = NormalizationBoost.LIMITER),
|
||||||
|
silent,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
assertEquals(GainMath.MAX_BOOST_DB, db)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cuts are never limited by the peak`() {
|
||||||
|
val hot = ReplayGain(trackGain = -9f, trackPeak = 1.4f, albumGain = null, albumPeak = null)
|
||||||
|
assertEquals(-9f, GainMath.gainDb(prefs(mode = NormalizationMode.TRACK), hot, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pos(album: String, track: Int?, disc: Int? = 1) = AlbumPosition(album, disc, track)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `in-order neighbours from the same album are album play`() {
|
||||||
|
assertTrue(GainMath.playingAsAlbum(null, pos("x", 1), pos("x", 2)))
|
||||||
|
assertTrue(GainMath.playingAsAlbum(pos("x", 1), pos("x", 2), pos("x", 3)))
|
||||||
|
assertTrue(GainMath.playingAsAlbum(pos("x", 2), pos("x", 3), null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a shuffled album, a mix and a lone track are not album play`() {
|
||||||
|
assertFalse(GainMath.playingAsAlbum(null, pos("x", 3), pos("x", 1)))
|
||||||
|
assertFalse(GainMath.playingAsAlbum(null, pos("x", 1), pos("y", 2)))
|
||||||
|
assertFalse(GainMath.playingAsAlbum(null, pos("x", 1), null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disc order counts`() {
|
||||||
|
assertTrue(GainMath.playingAsAlbum(pos("x", 12, disc = 1), pos("x", 1, disc = 2), null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a neighbour without a track number is not evidence of order`() {
|
||||||
|
assertFalse(GainMath.playingAsAlbum(pos("x", null), pos("x", 3), null))
|
||||||
|
assertFalse(GainMath.playingAsAlbum(null, pos("x", null), pos("x", null)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dbToLinear`() {
|
||||||
|
assertEquals(1f, GainMath.dbToLinear(0f))
|
||||||
|
assertEquals(0.1f, GainMath.dbToLinear(-20f), 1e-6f)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.fabledsword.minstrel.player.gain
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class GainStageTest {
|
||||||
|
|
||||||
|
private fun floats(vararg v: Float): ByteBuffer =
|
||||||
|
ByteBuffer.allocateDirect(v.size * 4).order(ByteOrder.nativeOrder()).apply {
|
||||||
|
v.forEach { putFloat(it) }
|
||||||
|
flip()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shorts(vararg v: Short): ByteBuffer =
|
||||||
|
ByteBuffer.allocateDirect(v.size * 2).order(ByteOrder.nativeOrder()).apply {
|
||||||
|
v.forEach { putShort(it) }
|
||||||
|
flip()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runFloat(stage: GainStage, input: ByteBuffer, target: Float, limiting: Boolean): FloatArray {
|
||||||
|
val out = ByteBuffer.allocateDirect(input.remaining()).order(ByteOrder.nativeOrder())
|
||||||
|
stage.process(input, out, channels = 2, isFloat = true, target = target, limiting = limiting)
|
||||||
|
out.flip()
|
||||||
|
return FloatArray(out.remaining() / 4) { out.float }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a stream that starts at its gain is scaled from the first sample`() {
|
||||||
|
val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 0.5f) }
|
||||||
|
val out = runFloat(stage, floats(0.8f, -0.4f), target = 0.5f, limiting = false)
|
||||||
|
assertEquals(0.4f, out[0], 1e-6f)
|
||||||
|
assertEquals(-0.2f, out[1], 1e-6f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a changed target ramps rather than steps`() {
|
||||||
|
val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 1f) }
|
||||||
|
val n = 44_100 // one second of stereo frames
|
||||||
|
val input = floats(*FloatArray(n * 2) { 0.5f })
|
||||||
|
val out = runFloat(stage, input, target = 0.5f, limiting = false)
|
||||||
|
assertTrue(out[0] > 0.49f, "first frame barely moved: ${out[0]}")
|
||||||
|
assertEquals(0.25f, out.last(), 1e-3f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the limiter holds a boosted peak at -1 dBFS`() {
|
||||||
|
val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 4f) }
|
||||||
|
val out = runFloat(stage, floats(0.9f, -0.9f, 0.1f, 0.1f), target = 4f, limiting = true)
|
||||||
|
out.forEach { assertTrue(abs(it) <= GainStage.LIMIT_CEILING + 1e-6f, "sample $it over the ceiling") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `without the limiter a boost is clamped to full scale, 16-bit included`() {
|
||||||
|
val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 4f) }
|
||||||
|
val input = shorts(20_000, -20_000)
|
||||||
|
val out = ByteBuffer.allocateDirect(4).order(ByteOrder.nativeOrder())
|
||||||
|
stage.process(input, out, channels = 2, isFloat = false, target = 4f, limiting = false)
|
||||||
|
out.flip()
|
||||||
|
assertEquals(Short.MAX_VALUE, out.short)
|
||||||
|
assertEquals((-Short.MAX_VALUE).toShort(), out.short)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unity is a pass-through`() {
|
||||||
|
val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 1f) }
|
||||||
|
assertTrue(stage.isUnity(target = 1f, limiting = false))
|
||||||
|
assertTrue(!stage.isUnity(target = 1f, limiting = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.fabledsword.minstrel.player.gain
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.ReplayGainApi
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedReplayGain
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||||
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
|
import com.fabledsword.minstrel.models.wire.ReplayGainResponseWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.ReplayGainWire
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
/** The fallback ladder: the library cache, then the server, then nothing. */
|
||||||
|
class ReplayGainStoreTest {
|
||||||
|
private val dao: CachedTrackDao = mockk()
|
||||||
|
private val api: ReplayGainApi = mockk()
|
||||||
|
private var health = ServerHealth.Healthy
|
||||||
|
|
||||||
|
private fun store() = ReplayGainStore(
|
||||||
|
scope = TestScope(),
|
||||||
|
trackDao = dao,
|
||||||
|
api = api,
|
||||||
|
serverHealth = { health },
|
||||||
|
clock = { 0L },
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cached gains are used and only the rest are asked of the server`() = runTest {
|
||||||
|
coEvery { dao.replayGains(listOf("a", "b", "c")) } returns listOf(
|
||||||
|
CachedReplayGain("a", trackGain = -6f, trackPeak = 1f, albumGain = -4f, albumPeak = 1f),
|
||||||
|
// Cached but unmeasured at the last sync: the server may know more.
|
||||||
|
CachedReplayGain("b", trackGain = null, trackPeak = null, albumGain = null, albumPeak = null),
|
||||||
|
)
|
||||||
|
coEvery { api.getReplayGain("b,c") } returns ReplayGainResponseWire(
|
||||||
|
items = mapOf("b" to ReplayGainWire(trackGain = -3f, trackPeak = 0.9f)),
|
||||||
|
)
|
||||||
|
val s = store()
|
||||||
|
|
||||||
|
s.load(listOf("a", "b", "c"))
|
||||||
|
|
||||||
|
assertEquals(-4f, s.get("a")?.albumGain)
|
||||||
|
assertEquals(-3f, s.get("b")?.trackGain)
|
||||||
|
assertNull(s.get("c"), "a track the server has not measured stays unknown")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `offline, the cache is all there is`() = runTest {
|
||||||
|
health = ServerHealth.Offline
|
||||||
|
coEvery { dao.replayGains(any()) } returns emptyList()
|
||||||
|
val s = store()
|
||||||
|
|
||||||
|
s.load(listOf("a"))
|
||||||
|
|
||||||
|
assertNull(s.get("a"))
|
||||||
|
coVerify(exactly = 0) { api.getReplayGain(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a failed lookup leaves the track unknown, to be asked again`() = runTest {
|
||||||
|
coEvery { dao.replayGains(any()) } returns emptyList()
|
||||||
|
coEvery { api.getReplayGain(any()) } throws IOException("reset")
|
||||||
|
val s = store()
|
||||||
|
|
||||||
|
s.load(listOf("a"))
|
||||||
|
|
||||||
|
assertNull(s.get("a"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
package com.fabledsword.minstrel.player.output
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Sonos queue plays straight through, so a track's album play is judged
|
||||||
|
* from its neighbours in the list sent (M464 #5002).
|
||||||
|
*/
|
||||||
|
class SonosAlbumPlayTest {
|
||||||
|
|
||||||
|
private fun t(album: String, n: Int?, disc: Int? = null) =
|
||||||
|
TrackRef(id = "$album-$disc-$n", title = "", albumId = album, artistId = "", trackNumber = n, discNumber = disc)
|
||||||
|
|
||||||
|
private fun asAlbum(queue: List<TrackRef>) = queue.indices.map { SonosQueueLoader.playingAsAlbum(queue, it) }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an album in order plays as an album throughout`() {
|
||||||
|
assertEquals(listOf(true, true, true), asAlbum(listOf(t("a", 1), t("a", 2), t("a", 3))))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a mix of albums plays as singles`() {
|
||||||
|
assertEquals(listOf(false, false, false), asAlbum(listOf(t("a", 1), t("b", 1), t("c", 4))))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `album order runs across discs`() {
|
||||||
|
assertEquals(listOf(true, true), asAlbum(listOf(t("a", 12, disc = 1), t("a", 1, disc = 2))))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `shuffled tracks of one album are not album play`() {
|
||||||
|
assertEquals(listOf(false, false), asAlbum(listOf(t("a", 3), t("a", 1))))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unnumbered tracks and missing albums are never album play`() {
|
||||||
|
assertEquals(listOf(false, false), asAlbum(listOf(t("a", null), t("a", null))))
|
||||||
|
assertEquals(listOf(false, false), asAlbum(listOf(t("", 1), t("", 2))))
|
||||||
|
}
|
||||||
|
}
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
package com.fabledsword.minstrel.settings.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.MeApi
|
||||||
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.MutationKind
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.verify
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The normalization preference is written offline-first (rule 100): the
|
||||||
|
* device shows the change at once, and a change the server has not seen
|
||||||
|
* yet is never overwritten or reordered.
|
||||||
|
*/
|
||||||
|
class NormalizationRepositoryTest {
|
||||||
|
private val api: MeApi = mockk()
|
||||||
|
private val authStore: AuthStore = mockk(relaxed = true) {
|
||||||
|
every { normalization } returns MutableStateFlow(NormalizationPrefs.DEFAULT)
|
||||||
|
}
|
||||||
|
private val queue: MutationQueue = mockk(relaxed = true)
|
||||||
|
private val dao: CachedMutationDao = mockk()
|
||||||
|
private val retrofit: Retrofit = mockk {
|
||||||
|
every { create(MeApi::class.java) } returns api
|
||||||
|
}
|
||||||
|
private val repo = NormalizationRepository(retrofit, authStore, queue, dao)
|
||||||
|
|
||||||
|
private val album = NormalizationPrefs.DEFAULT.copy(mode = NormalizationMode.ALBUM)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a change is shown at once and sent`() = runTest {
|
||||||
|
coEvery { dao.hasPending(MutationKind.NORMALIZATION_SET) } returns false
|
||||||
|
coEvery { api.putNormalization(album) } returns album
|
||||||
|
|
||||||
|
repo.set(album)
|
||||||
|
|
||||||
|
verify { authStore.setNormalization(album) }
|
||||||
|
coVerify { api.putNormalization(album) }
|
||||||
|
coVerify(exactly = 0) { queue.enqueueNormalizationSet(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a change that cannot reach the server is queued`() = runTest {
|
||||||
|
coEvery { dao.hasPending(MutationKind.NORMALIZATION_SET) } returns false
|
||||||
|
coEvery { api.putNormalization(album) } throws IOException("offline")
|
||||||
|
|
||||||
|
repo.set(album)
|
||||||
|
|
||||||
|
verify { authStore.setNormalization(album) }
|
||||||
|
coVerify { queue.enqueueNormalizationSet(album) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a change behind a queued one queues too, so the older one cannot land last`() = runTest {
|
||||||
|
coEvery { dao.hasPending(MutationKind.NORMALIZATION_SET) } returns true
|
||||||
|
|
||||||
|
repo.set(album)
|
||||||
|
|
||||||
|
coVerify(exactly = 0) { api.putNormalization(any()) }
|
||||||
|
coVerify { queue.enqueueNormalizationSet(album) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `refresh takes the server value`() = runTest {
|
||||||
|
coEvery { api.getNormalization() } returns album
|
||||||
|
coEvery { dao.hasPending(MutationKind.NORMALIZATION_SET) } returns false
|
||||||
|
|
||||||
|
repo.refresh()
|
||||||
|
|
||||||
|
verify { authStore.setNormalization(album) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `refresh leaves a queued change on screen`() = runTest {
|
||||||
|
coEvery { api.getNormalization() } returns NormalizationPrefs.DEFAULT
|
||||||
|
coEvery { dao.hasPending(MutationKind.NORMALIZATION_SET) } returns true
|
||||||
|
|
||||||
|
repo.refresh()
|
||||||
|
|
||||||
|
verify(exactly = 0) { authStore.setNormalization(any()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ kotlinx-serialization = "1.7.3"
|
|||||||
kotlinx-coroutines = "1.9.0"
|
kotlinx-coroutines = "1.9.0"
|
||||||
kotlinx-datetime = "0.6.1"
|
kotlinx-datetime = "0.6.1"
|
||||||
kotlinx-serialization-converter = "1.0.0"
|
kotlinx-serialization-converter = "1.0.0"
|
||||||
media3 = "1.10.1"
|
media3 = "1.11.0"
|
||||||
mediarouter = "1.7.0"
|
mediarouter = "1.7.0"
|
||||||
coil = "3.0.0-rc02"
|
coil = "3.0.0-rc02"
|
||||||
palette = "1.0.0"
|
palette = "1.0.0"
|
||||||
|
|||||||
+9
-8
@@ -12,8 +12,9 @@ git.fabledsword.com/bvandeusen/ci-go:1.26
|
|||||||
git.fabledsword.com/bvandeusen/ci-android:36
|
git.fabledsword.com/bvandeusen/ci-android:36
|
||||||
```
|
```
|
||||||
|
|
||||||
- `ci-go:1.26` — Go server tests (`.gitea/workflows/test-go.yml`), web SPA tests (`.gitea/workflows/test-web.yml`), and the release container build (`release.yml`'s `image-release` job).
|
- `ci-go:1.26` — the `go`, `integration` and `web` lanes, `release-assets`, and the container build (`image-release`). All CI lives in one workflow, `.gitea/workflows/release.yml`, so every publishing job can depend on every lane (rule 177).
|
||||||
- `ci-android:36` — native Kotlin/Compose client: ktlint + detekt + unit tests + debug APK (`.gitea/workflows/android.yml`), and the signed release APK (`release.yml`'s `android-release` job).
|
- `golang:1.26-bookworm` (Docker Hub) — the `govulncheck` lane. Deliberately the same image as the Dockerfile's builder stage rather than `ci-go`, so the standard library it checks is the one in the shipped binary. Keep the two in step.
|
||||||
|
- `ci-android:36` — native Kotlin/Compose client: ktlint + detekt + unit tests + debug APK (the `android` lane), and the signed release APK (`android-release`).
|
||||||
|
|
||||||
**`ci-flutter` is no longer consumed, and `ci-flutter` can now be retired.**
|
**`ci-flutter` is no longer consumed, and `ci-flutter` can now be retired.**
|
||||||
The M8 rewrite replaced the Flutter client with the native Android app and
|
The M8 rewrite replaced the Flutter client with the native Android app and
|
||||||
@@ -30,9 +31,9 @@ split below. The label is a scheduling handle, not a toolchain assertion.
|
|||||||
|
|
||||||
### From `ci-go:1.26`
|
### From `ci-go:1.26`
|
||||||
- **Go** (1.26 toolchain) — `go vet`, `go test -race`, `go build`, `go mod`.
|
- **Go** (1.26 toolchain) — `go vet`, `go test -race`, `go build`, `go mod`.
|
||||||
- **Node + npm** — `npm ci` and `npm test` / `npm run check` in `test-web.yml`.
|
- **Node + npm** — `npm ci`, `npm audit`, `npm test` and `npm run check` in the `web` lane.
|
||||||
- **golangci-lint** — lint pass in `test-go.yml`.
|
- **golangci-lint** — lint pass in the `go` lane.
|
||||||
- **docker CLI** — bridge-IP discovery of the per-job Postgres service container in `test-go.yml` integration job (via the runner's shared `/var/run/docker.sock`).
|
- **docker CLI** — bridge-IP discovery of the per-job Postgres service container in the `integration` lane (via the runner's shared `/var/run/docker.sock`).
|
||||||
- **docker buildx** — release container build + push in `release.yml`.
|
- **docker buildx** — release container build + push in `release.yml`.
|
||||||
- **curl** — release-asset polling / upload in `release.yml`.
|
- **curl** — release-asset polling / upload in `release.yml`.
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ split below. The label is a scheduling handle, not a toolchain assertion.
|
|||||||
No NDK: the native client has no C/C++ sources (this is why it isn't on
|
No NDK: the native client has no C/C++ sources (this is why it isn't on
|
||||||
`ci-flutter`).
|
`ci-flutter`).
|
||||||
- **ktlint + detekt** — `./gradlew ktlintCheck` and `./gradlew detekt` in
|
- **ktlint + detekt** — `./gradlew ktlintCheck` and `./gradlew detekt` in
|
||||||
`android.yml`. Image pins track `android/gradle/libs.versions.toml` so local
|
the `android` lane. Image pins track `android/gradle/libs.versions.toml` so local
|
||||||
and CI checks agree.
|
and CI checks agree.
|
||||||
- **git** — `actions/checkout@v4` baseline (and any shell git operations).
|
- **git** — `actions/checkout@v4` baseline (and any shell git operations).
|
||||||
- **base64 + curl** — keystore decode + release-asset upload in `release.yml`'s
|
- **base64 + curl** — keystore decode + release-asset upload in `release.yml`'s
|
||||||
@@ -58,10 +59,10 @@ None.
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- **Label/image split.** Workflows keep `runs-on: go-ci` / `runs-on: flutter-ci` as the scheduling label per the [`ci-runners.md`](https://…/FabledRulebook/ci-runners.md) "label = scheduling handle, image = `container.image`" pattern. The labels are intentional handles, not toolchain assertions — which is why the Android jobs still schedule on `flutter-ci` while pulling `ci-android:36`. Switch them to `android-ci` if that runner label is ever registered; nothing breaks either way.
|
- **Label/image split.** Workflows keep `runs-on: go-ci` / `runs-on: flutter-ci` as the scheduling label per the [`ci-runners.md`](https://…/FabledRulebook/ci-runners.md) "label = scheduling handle, image = `container.image`" pattern. The labels are intentional handles, not toolchain assertions — which is why the Android jobs still schedule on `flutter-ci` while pulling `ci-android:36`. Switch them to `android-ci` if that runner label is ever registered; nothing breaks either way.
|
||||||
- **Integration-job docker-socket dependency.** `test-go.yml`'s integration job uses the runner's shared docker socket (`/var/run/docker.sock`) to bridge-IP-discover the per-job Postgres service container by name + network intersection — the dev compose's `minstrel-postgres-*` containers are explicitly skipped as belt-and-suspenders. Depends on `act_runner.valid_volumes` whitelisting the socket; if that ever stops auto-mounting, integration tests fail at the `docker inspect` step.
|
- **Integration-job docker-socket dependency.** The `integration` lane uses the runner's shared docker socket (`/var/run/docker.sock`) to bridge-IP-discover the per-job Postgres service container by name + network intersection — the dev compose's `minstrel-postgres-*` containers are explicitly skipped as belt-and-suspenders. Depends on `act_runner.valid_volumes` whitelisting the socket; if that ever stops auto-mounting, integration tests fail at the `docker inspect` step.
|
||||||
- **Go toolchain pin.** `go.mod` is on `go 1.25.0` because `golang.org/x/crypto v0.51.0` declares 1.25 as its minimum. `ci-go:1.26` satisfies this with headroom. Future `x/crypto` bumps that move the Go floor should be paired with an image-tag bump in this file + the workflows.
|
- **Go toolchain pin.** `go.mod` is on `go 1.25.0` because `golang.org/x/crypto v0.51.0` declares 1.25 as its minimum. `ci-go:1.26` satisfies this with headroom. Future `x/crypto` bumps that move the Go floor should be paired with an image-tag bump in this file + the workflows.
|
||||||
- **In-app update channel — `needs:`, not polling.** `release.yml`'s `image-release` job declares `needs: [android-release]`, so on tag pushes the signed APK is guaranteed present before the image build starts — no polling window, no race. (The old cross-workflow polling against `flutter.yml` is gone with that workflow.) On non-tag `main` pushes `android-release` is skipped and `image-release` instead pulls the most recent release's APK and reconstructs its exact `versionName`, so `:latest` never ships without an update channel. It degrades to an empty `client/` — never a wrong version — if no release, asset, or tag commit-count can be resolved.
|
- **In-app update channel — `needs:`, not polling.** `release.yml`'s `image-release` job declares `needs: [android-release]`, so on tag pushes the signed APK is guaranteed present before the image build starts — no polling window, no race. (The old cross-workflow polling against `flutter.yml` is gone with that workflow.) On non-tag `main` pushes `android-release` is skipped and `image-release` instead pulls the most recent release's APK and reconstructs its exact `versionName`, so `:latest` never ships without an update channel. It degrades to an empty `client/` — never a wrong version — if no release, asset, or tag commit-count can be resolved.
|
||||||
- **Cache server reachability.** `test-web.yml` does NOT use `cache: 'npm'` on `actions/setup-node` — the Gitea Actions cache server isn't reachable from this runner's container network and `setup-node` was burning ~4m41s on ETIMEDOUT before failing open. With the migration to `ci-go:1.26`, `setup-node` is removed entirely (Node is in the image). The cache concern reappears if a future change re-introduces a network-dependent action.
|
- **Cache server reachability.** The `web` lane does NOT use `cache: 'npm'` on `actions/setup-node` — the Gitea Actions cache server isn't reachable from this runner's container network and `setup-node` was burning ~4m41s on ETIMEDOUT before failing open. With the migration to `ci-go:1.26`, `setup-node` is removed entirely (Node is in the image). The cache concern reappears if a future change re-introduces a network-dependent action.
|
||||||
- **Artifacts — stock `actions/upload-artifact@v7` and `actions/download-artifact@v8`; never `@v3`.**
|
- **Artifacts — stock `actions/upload-artifact@v7` and `actions/download-artifact@v8`; never `@v3`.**
|
||||||
```yaml
|
```yaml
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
|
|||||||
+8
-13
@@ -55,12 +55,14 @@ func runAdmin(args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminResetPassword resets a user's credentials. It updates BOTH
|
// adminResetPassword resets a user's login password (password_hash). It
|
||||||
// password_hash (bcrypt, for /api/auth/login) and subsonic_password
|
// recovers a locked-out operator when the bootstrap password was missed or
|
||||||
// (plaintext, required for Subsonic t+s token verification) so neither
|
// the DB volume was recreated (Fable #321) without DB surgery.
|
||||||
// auth path is left stale. Recovers a locked-out operator when the
|
//
|
||||||
// bootstrap password was missed or the DB volume was recreated (Fable
|
// It deliberately leaves subsonic_password alone. That column is stored in
|
||||||
// #321) without DB surgery.
|
// plain text, and it used to receive the new login password here, so any
|
||||||
|
// account recovered this way had its login password readable in the database
|
||||||
|
// (#5026). The Subsonic password is generated separately in Settings.
|
||||||
func adminResetPassword(args []string) error {
|
func adminResetPassword(args []string) error {
|
||||||
fs := flag.NewFlagSet("admin reset-password", flag.ContinueOnError)
|
fs := flag.NewFlagSet("admin reset-password", flag.ContinueOnError)
|
||||||
configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
|
configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
|
||||||
@@ -112,13 +114,6 @@ func adminResetPassword(args []string) error {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("update password_hash: %w", err)
|
return fmt.Errorf("update password_hash: %w", err)
|
||||||
}
|
}
|
||||||
sp := pw
|
|
||||||
if err := q.SetSubsonicPassword(ctx, dbq.SetSubsonicPasswordParams{
|
|
||||||
ID: user.ID,
|
|
||||||
SubsonicPassword: &sp,
|
|
||||||
}); err != nil {
|
|
||||||
return fmt.Errorf("update subsonic_password: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if generated {
|
if generated {
|
||||||
fmt.Printf("minstrel: password for %q reset.\nNew password: %s\n", *username, pw)
|
fmt.Printf("minstrel: password for %q reset.\nNew password: %s\n", *username, pw)
|
||||||
|
|||||||
+58
-1
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/acoustid"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coplay"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coplay"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||||
@@ -122,7 +123,31 @@ func run() error {
|
|||||||
}
|
}
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
scanner := library.New(pool, logger, cfg.Library.ScanPaths)
|
// Fingerprinting settings (M400 #3913): one instance, shared by the scanner,
|
||||||
|
// the fingerprint backfill, the duplicate sweep and the admin API, so a save
|
||||||
|
// reaches all of them without a restart. A load failure is logged, not fatal:
|
||||||
|
// the service falls back to the shipped defaults.
|
||||||
|
fpSettings, fpErr := library.NewFingerprintSettingsService(ctx, pool)
|
||||||
|
if fpErr != nil {
|
||||||
|
logger.Warn("fingerprint settings: using defaults", "err", fpErr)
|
||||||
|
}
|
||||||
|
scanner := library.New(pool, logger, cfg.Library.ScanPaths, fpSettings)
|
||||||
|
|
||||||
|
// Loudness analysis settings (M464 #4995): shared by the loudness backfill
|
||||||
|
// and the admin API, and falls back to the defaults like the above.
|
||||||
|
loudSettings, loudErr := library.NewLoudnessSettingsService(ctx, pool)
|
||||||
|
if loudErr != nil {
|
||||||
|
logger.Warn("loudness settings: using defaults", "err", loudErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcoustID lookup settings (M401): shared by the lookup worker and the
|
||||||
|
// admin API. A load failure leaves the lookup off.
|
||||||
|
acoustIDSettings, acoustIDErr := library.NewAcoustIDSettingsService(ctx, pool)
|
||||||
|
if acoustIDErr != nil {
|
||||||
|
logger.Warn("acoustid settings: using defaults (lookup off)", "err", acoustIDErr)
|
||||||
|
}
|
||||||
|
acoustIDLookup := library.NewAcoustIDLookupWorker(pool, logger.With("component", "acoustid_lookup"),
|
||||||
|
acoustIDSettings, acoustid.New(acoustid.DefaultBaseURL))
|
||||||
|
|
||||||
contact := cfg.Library.ContactEmail
|
contact := cfg.Library.ContactEmail
|
||||||
if contact == "" {
|
if contact == "" {
|
||||||
@@ -214,6 +239,27 @@ func run() error {
|
|||||||
// SQL, no external calls; empty on single-user servers.
|
// SQL, no external calls; empty on single-user servers.
|
||||||
go coplay.NewWorker(pool, logger.With("component", "coplay")).Run(ctx)
|
go coplay.NewWorker(pool, logger.With("component", "coplay")).Run(ctx)
|
||||||
|
|
||||||
|
// Fingerprint backfill (M400 #3908): fingerprints the tracks the scan never
|
||||||
|
// will — everything imported before fingerprinting existed, and rows derived
|
||||||
|
// by an older method. A worker of its own rather than a scan stage; see
|
||||||
|
// internal/library/fingerprint_backfill.go for why.
|
||||||
|
go library.NewFingerprintBackfillWorker(pool, logger.With("component", "fingerprint_backfill"), fpSettings).Run(ctx)
|
||||||
|
|
||||||
|
// Loudness backfill (M464 #4995): measures every track's loudness for
|
||||||
|
// normalization, new tracks included; the scan only drops a changed file's
|
||||||
|
// measurement. See internal/library/loudness_backfill.go.
|
||||||
|
go library.NewLoudnessBackfillWorker(pool, logger.With("component", "loudness_backfill"), loudSettings).Run(ctx)
|
||||||
|
|
||||||
|
// AcoustID lookup (M401): fills the recording MBIDs untagged tracks lack,
|
||||||
|
// so the similarity arms can see them. Idle until the operator adds a key
|
||||||
|
// and switches it on. See internal/library/acoustid_lookup.go.
|
||||||
|
go acoustIDLookup.Run(ctx)
|
||||||
|
|
||||||
|
// Duplicate sweep (M400 #3910): proposes groups of tracks holding one
|
||||||
|
// recording, from the fingerprints above. Sweeps only when fingerprints have
|
||||||
|
// changed since the last sweep.
|
||||||
|
go library.NewDuplicateSweepWorker(pool, logger.With("component", "duplicate_sweep"), fpSettings).Run(ctx)
|
||||||
|
|
||||||
// Start the tag-enrichment worker (#1490). Reconciles the compiled-in
|
// Start the tag-enrichment worker (#1490). Reconciles the compiled-in
|
||||||
// tag providers with tag_provider_settings, bumps the sources version if
|
// tag providers with tag_provider_settings, bumps the sources version if
|
||||||
// the provider set changed (re-opening settled rows), then drains tracks
|
// the provider set changed (re-opening settled rows), then drains tracks
|
||||||
@@ -357,11 +403,22 @@ func run() error {
|
|||||||
srv.PlaylistScheduler = playlistScheduler
|
srv.PlaylistScheduler = playlistScheduler
|
||||||
srv.RecSettings = recSettings
|
srv.RecSettings = recSettings
|
||||||
srv.TagSettings = tagSettings
|
srv.TagSettings = tagSettings
|
||||||
|
srv.FingerprintSettings = fpSettings
|
||||||
|
srv.LoudnessSettings = loudSettings
|
||||||
|
srv.AcoustIDLookup = acoustIDLookup
|
||||||
|
// The sweeper above holds this same instance, so a save from the admin
|
||||||
|
// card changes what it does on its next tick (#3936).
|
||||||
|
srv.ReacqSettings = reacqSettings
|
||||||
srv.StreamSecret = cfg.StreamSecret
|
srv.StreamSecret = cfg.StreamSecret
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: cfg.Server.Address,
|
Addr: cfg.Server.Address,
|
||||||
Handler: srv.Router(),
|
Handler: srv.Router(),
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
// Closes keep-alive connections nobody is using. Deliberately no
|
||||||
|
// ReadTimeout or WriteTimeout: either would cut off audio streams and
|
||||||
|
// the SSE event stream. Request bodies get their own deadline in the
|
||||||
|
// server's limitRequestBody middleware instead.
|
||||||
|
IdleTimeout: 120 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ version: "3.9"
|
|||||||
# tests at it:
|
# tests at it:
|
||||||
# make test-integration
|
# make test-integration
|
||||||
# (CI runs the same suite against its own ephemeral Postgres service —
|
# (CI runs the same suite against its own ephemeral Postgres service —
|
||||||
# see .gitea/workflows/test-go.yml.)
|
# see the `integration` job in .gitea/workflows/release.yml.)
|
||||||
#
|
#
|
||||||
# Full stack (server + db):
|
# Full stack (server + db):
|
||||||
# docker compose up --build
|
# docker compose up --build
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Hosting Minstrel
|
||||||
|
|
||||||
|
Minstrel serves plain HTTP on port 4533 and never terminates TLS itself. On a
|
||||||
|
home network or a VPN you trust, that is all you need. Once the server is
|
||||||
|
reachable from the internet, put it behind a reverse proxy that speaks HTTPS,
|
||||||
|
and tell Minstrel about that proxy. This page covers both.
|
||||||
|
|
||||||
|
## On a LAN or VPN
|
||||||
|
|
||||||
|
Publish the port to the network and use `http://<host>:4533`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ports: ['4533:4533']
|
||||||
|
```
|
||||||
|
|
||||||
|
If nothing sits in front of Minstrel, set **Admin → Integrations → Client IP
|
||||||
|
detection** to 0. It defaults to 1, which assumes one proxy (see below for
|
||||||
|
why that matters).
|
||||||
|
|
||||||
|
## On the internet
|
||||||
|
|
||||||
|
Three things, all required:
|
||||||
|
|
||||||
|
1. **Don't publish 4533 to the world.** Bind it to the loopback interface so
|
||||||
|
only the proxy on the same host can reach it:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ports: ['127.0.0.1:4533:4533']
|
||||||
|
```
|
||||||
|
|
||||||
|
If the proxy runs in another container on the same Docker network, drop
|
||||||
|
`ports:` entirely and point the proxy at `minstrel:4533`.
|
||||||
|
|
||||||
|
2. **Terminate HTTPS at a reverse proxy.** Any proxy works. With Caddy, which
|
||||||
|
gets and renews certificates by itself:
|
||||||
|
|
||||||
|
```
|
||||||
|
music.example.com {
|
||||||
|
reverse_proxy 127.0.0.1:4533
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Two settings matter for every proxy:
|
||||||
|
- **Don't buffer responses.** Live updates use Server-Sent Events and audio
|
||||||
|
is streamed; a buffering proxy holds both back. Caddy streams by default.
|
||||||
|
For nginx, set `proxy_buffering off;`.
|
||||||
|
- **Allow long-lived responses.** An event stream stays open for as long as
|
||||||
|
the app is. Raise the proxy's read timeout well past a minute (nginx:
|
||||||
|
`proxy_read_timeout 1h;`).
|
||||||
|
|
||||||
|
3. **Tell Minstrel where it lives**, in **Admin → Integrations**:
|
||||||
|
- **Client IP detection:** the number of proxies in front of Minstrel. One
|
||||||
|
proxy (Caddy, Traefik, nginx) is 1; Cloudflare in front of Traefik is 2.
|
||||||
|
Minstrel uses this to find the real client address, for login rate
|
||||||
|
limits and the sessions list, and to tell whether a request arrived over
|
||||||
|
HTTPS.
|
||||||
|
- **Public address:** the URL people use, e.g. `https://music.example.com`.
|
||||||
|
Password-reset emails link here. **Until it is set, no reset email is
|
||||||
|
sent**, so a forged `Host` header can never put a reset token on a link
|
||||||
|
to someone else's server.
|
||||||
|
|
||||||
|
### Why the proxy count matters
|
||||||
|
|
||||||
|
Minstrel only believes `X-Forwarded-For` and `X-Forwarded-Proto` from the
|
||||||
|
number of proxies you configure, counted from the right. Set it too high, or
|
||||||
|
leave it at the default of 1 with no proxy in front, and a client can write
|
||||||
|
those headers itself: it could claim any address to slip past the per-address
|
||||||
|
login limit, or claim HTTPS. With no proxy, set it to 0.
|
||||||
|
|
||||||
|
When the proxy reports HTTPS, Minstrel marks the session cookie `Secure` and
|
||||||
|
sends `Strict-Transport-Security`. Over plain HTTP it does neither, and it
|
||||||
|
never redirects to HTTPS, so LAN use keeps working unchanged.
|
||||||
|
|
||||||
|
## First account
|
||||||
|
|
||||||
|
A fresh server with no accounts prints a **setup token** in its log:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose logs minstrel | grep setup_token
|
||||||
|
```
|
||||||
|
|
||||||
|
Creating the first account (which becomes the admin) requires that token, so
|
||||||
|
whoever reaches a newly exposed server first can't claim it. The token
|
||||||
|
changes on every restart until an account exists.
|
||||||
|
|
||||||
|
## Android app
|
||||||
|
|
||||||
|
The app connects over whatever URL you give it, HTTP or HTTPS. Once the
|
||||||
|
server has a public HTTPS address, give the app that address so the session
|
||||||
|
cookie never crosses the internet unencrypted. See
|
||||||
|
[security notes](./security.md#android-allows-plain-http) for why plain HTTP
|
||||||
|
is still allowed.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Security notes
|
||||||
|
|
||||||
|
What Minstrel does to protect accounts, and the reasoning behind the
|
||||||
|
decisions that look odd at first. For deployment steps, see
|
||||||
|
[hosting](./hosting.md).
|
||||||
|
|
||||||
|
## Accounts and sessions
|
||||||
|
|
||||||
|
- **Passwords** are stored as bcrypt hashes.
|
||||||
|
- **Login, registration and password reset are rate-limited:** 10 failed
|
||||||
|
sign-ins per account and 50 per address per 15 minutes, with similar limits
|
||||||
|
on register, forgot-password and reset. The Subsonic `/rest` API shares the
|
||||||
|
login limits. An unknown username takes as long to reject as a wrong
|
||||||
|
password, so timing doesn't reveal which accounts exist.
|
||||||
|
- **Sessions** are random 256-bit tokens; the server stores only their
|
||||||
|
SHA-256. A session ends after 30 days unused or 365 days in total. Changing
|
||||||
|
your password signs out your other devices; a password reset, or an admin
|
||||||
|
resetting it, signs out all of them.
|
||||||
|
- **API keys** (the OpenSubsonic `apiKey`) are stored as SHA-256 too, so a
|
||||||
|
key is shown once, when you generate it in Settings, and can only be
|
||||||
|
replaced after that.
|
||||||
|
- **The first account** on an empty server needs the setup token from the
|
||||||
|
server log (see [hosting](./hosting.md#first-account)).
|
||||||
|
- **Password-reset links** are built only from the configured public address,
|
||||||
|
never from the request's `Host` header.
|
||||||
|
|
||||||
|
## Requests
|
||||||
|
|
||||||
|
- Request bodies are capped at 4 MiB and must arrive within 30 seconds.
|
||||||
|
Streams and the live-event connection are unaffected.
|
||||||
|
- Every response carries `X-Content-Type-Options: nosniff`,
|
||||||
|
`Referrer-Policy: strict-origin-when-cross-origin`, a restrictive
|
||||||
|
`Permissions-Policy` and `X-Frame-Options: DENY`. The web app also gets a
|
||||||
|
`Content-Security-Policy` that allows only its own scripts, by hash.
|
||||||
|
- Media responses are `Cache-Control: private`, so a shared cache never keeps
|
||||||
|
one user's audio or artwork for another.
|
||||||
|
|
||||||
|
## CSRF: SameSite cookies plus JSON-only writes
|
||||||
|
|
||||||
|
There are no CSRF tokens. The session cookie is `SameSite=Strict`, so
|
||||||
|
browsers don't send it with requests that start on another site. That leaves
|
||||||
|
one gap: SameSite treats every subdomain of the same registrable domain as the
|
||||||
|
same site, so a different app on `other.example.com` could still send a
|
||||||
|
request carrying the cookie. To close it, any state-changing `/api` request
|
||||||
|
authenticated by the cookie must have a JSON body (`Content-Type:
|
||||||
|
application/json`) or no body at all. An HTML form or a script on another
|
||||||
|
origin can't send JSON without a CORS preflight, and Minstrel answers no
|
||||||
|
cross-origin preflight. Requests authenticated with a bearer token or the
|
||||||
|
Subsonic query parameters carry nothing a browser attaches on its own, so
|
||||||
|
they aren't checked.
|
||||||
|
|
||||||
|
## Subsonic sign-in, and the one password stored in plain text
|
||||||
|
|
||||||
|
Classic Subsonic clients sign in with `t` and `s`: the MD5 of the password
|
||||||
|
followed by a random salt. To check that, the server has to know the password
|
||||||
|
itself, so supporting this sign-in method means storing a password Minstrel
|
||||||
|
can read. That is the Subsonic password, and it is the only credential
|
||||||
|
Minstrel keeps unhashed.
|
||||||
|
|
||||||
|
- **The recommended way in is the API key.** Clients that support the
|
||||||
|
OpenSubsonic `apiKey` should use it. The key is stored hashed and can be
|
||||||
|
replaced at any time in Settings.
|
||||||
|
- **The Subsonic password is never your login password.** Minstrel generates
|
||||||
|
it (**Settings → Subsonic password**), shows it once, and lets you replace
|
||||||
|
or turn it off. Because it is random, a copy of the database exposes access
|
||||||
|
to this server's Subsonic API and nothing else: it can't be a password you
|
||||||
|
also use somewhere else.
|
||||||
|
- **`t`/`s` and `p=` sign-in are off for an account until it has a Subsonic
|
||||||
|
password.** Plain `p=` sign-in is additionally off server-wide unless
|
||||||
|
`subsonic.allow_plaintext_password` is enabled.
|
||||||
|
- `minstrel admin reset-password` changes only the login password. Older
|
||||||
|
versions also copied it into the Subsonic password; upgrading clears every
|
||||||
|
Subsonic password once, so those copies are gone. An account that used
|
||||||
|
`t`/`s` sign-in needs a new Subsonic password generated in Settings.
|
||||||
|
|
||||||
|
## Android allows plain HTTP
|
||||||
|
|
||||||
|
The Android app permits cleartext connections, for two reasons that can't be
|
||||||
|
narrowed to a list of hosts. The server address is whatever the user types,
|
||||||
|
and many self-hosted servers run plain HTTP on a LAN. And UPnP, DLNA and
|
||||||
|
Sonos speakers are controlled over plain HTTP at addresses that are only
|
||||||
|
known once they're discovered. The reasoning lives in
|
||||||
|
`android/app/src/main/res/xml/network_security_config.xml`.
|
||||||
|
|
||||||
|
This doesn't weaken app updates: an APK altered in transit fails the
|
||||||
|
platform's signature check. It does mean a server reached over plain HTTP
|
||||||
|
across the internet exposes the session cookie in transit, which is why the
|
||||||
|
[hosting guide](./hosting.md) puts public servers behind HTTPS.
|
||||||
|
|
||||||
|
On the phone, the session cookie is encrypted with a key held in the Android
|
||||||
|
Keystore, so a copy of the app's files doesn't yield a usable session.
|
||||||
|
|
||||||
|
## Build pipeline
|
||||||
|
|
||||||
|
Nothing is published unless every check passes: the Go, integration, web and
|
||||||
|
Android test suites, `govulncheck` against the toolchain that builds the
|
||||||
|
image, and `npm audit` on the packages that ship to the browser. See
|
||||||
|
`.gitea/workflows/release.yml`.
|
||||||
|
|
||||||
|
## Reporting a problem
|
||||||
|
|
||||||
|
Open an issue on the
|
||||||
|
[repository](https://git.fabledsword.com/bvandeusen/minstrel/issues), or
|
||||||
|
contact the maintainer privately first if it's something that shouldn't be
|
||||||
|
public until fixed.
|
||||||
@@ -13,6 +13,7 @@ require (
|
|||||||
github.com/jackc/pgx/v5 v5.9.2
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/crypto v0.51.0
|
golang.org/x/crypto v0.51.0
|
||||||
|
golang.org/x/sync v0.21.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,7 +26,6 @@ require (
|
|||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
|
||||||
golang.org/x/sys v0.44.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/text v0.39.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -95,12 +95,12 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
|||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
// Package acoustid looks up MusicBrainz recording ids by acoustic fingerprint
|
||||||
|
// through the AcoustID web service (M401).
|
||||||
|
//
|
||||||
|
// What it relies on, from https://acoustid.org/webservice (read 2026-10-06):
|
||||||
|
// - POST https://api.acoustid.org/v2/lookup with client, duration (whole
|
||||||
|
// seconds), fingerprint (fpcalc's compressed string) and meta. A gzip body
|
||||||
|
// with Content-Encoding: gzip is accepted and preferred, because
|
||||||
|
// fingerprints are long.
|
||||||
|
// - meta=recordings returns each recording's title, duration and artists,
|
||||||
|
// which the worker needs to tell several candidates apart (M401 D5).
|
||||||
|
// - "Do not make more than 3 requests per second."
|
||||||
|
// - The service is free for non-commercial use only.
|
||||||
|
//
|
||||||
|
// Error codes are the server's own, from acoustid-server's
|
||||||
|
// acoustid/api/errors.py.
|
||||||
|
package acoustid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"cmp"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultBaseURL is the lookup endpoint.
|
||||||
|
const DefaultBaseURL = "https://api.acoustid.org/v2/lookup"
|
||||||
|
|
||||||
|
const (
|
||||||
|
// minInterval keeps to AcoustID's 3 requests a second with headroom: one
|
||||||
|
// every 400ms is 2.5 a second.
|
||||||
|
minInterval = 400 * time.Millisecond
|
||||||
|
// lookupTimeout bounds one request, connection included (rule 156). A
|
||||||
|
// lookup answers in well under a second; 20s is the line past which slow
|
||||||
|
// has become never.
|
||||||
|
lookupTimeout = 20 * time.Second
|
||||||
|
// bodyLimit caps the response read. A lookup with meta=recordings for a
|
||||||
|
// popular song runs to tens of kilobytes.
|
||||||
|
bodyLimit = 2 << 20
|
||||||
|
userAgent = "Minstrel ( https://git.fabledsword.com/bvandeusen/minstrel )"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The server's error codes that the worker treats differently.
|
||||||
|
const (
|
||||||
|
codeInvalidFingerprint = 3
|
||||||
|
codeInvalidAPIKey = 4
|
||||||
|
codeServiceUnavailable = 13
|
||||||
|
codeTooManyRequests = 14
|
||||||
|
codeUnknownApplication = 17
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrInvalidKey: the operator's application key was refused. No lookup
|
||||||
|
// can succeed until it is changed, so the worker stops and says so.
|
||||||
|
ErrInvalidKey = errors.New("acoustid: the API key was refused")
|
||||||
|
// ErrInvalidFingerprint: AcoustID rejected this track's fingerprint. A
|
||||||
|
// verdict on the track, not on the service.
|
||||||
|
ErrInvalidFingerprint = errors.New("acoustid: the fingerprint was rejected")
|
||||||
|
// ErrUnavailable: the service could not answer now (unreachable, timed
|
||||||
|
// out, overloaded or rate limiting). Says nothing about the track, so the
|
||||||
|
// lookup is tried again later.
|
||||||
|
ErrUnavailable = errors.New("acoustid: the service is unavailable")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Recording is one MusicBrainz recording a fingerprint matched.
|
||||||
|
type Recording struct {
|
||||||
|
ID string
|
||||||
|
Title string
|
||||||
|
// DurationSec is MusicBrainz's length for the recording; 0 when unknown.
|
||||||
|
DurationSec int
|
||||||
|
Artists []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidate is a recording with the score of the best AcoustID result that
|
||||||
|
// linked to it.
|
||||||
|
type Candidate struct {
|
||||||
|
Recording
|
||||||
|
Score float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client calls the lookup endpoint, at most one request per minInterval.
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
http *http.Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
lastCall time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a client for baseURL (DefaultBaseURL in production).
|
||||||
|
func New(baseURL string) *Client {
|
||||||
|
return &Client{baseURL: baseURL, http: &http.Client{Timeout: lookupTimeout}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup asks which recordings match fingerprint, a compressed chromaprint
|
||||||
|
// of audio durationSec long. It returns every candidate, best score first;
|
||||||
|
// choosing among them is the caller's job. No match is an empty slice and
|
||||||
|
// no error.
|
||||||
|
func (c *Client) Lookup(ctx context.Context, apiKey, fingerprint string, durationSec int) ([]Candidate, error) {
|
||||||
|
if err := c.wait(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, err := gzipForm(url.Values{
|
||||||
|
"client": {apiKey},
|
||||||
|
"duration": {strconv.Itoa(durationSec)},
|
||||||
|
"fingerprint": {fingerprint},
|
||||||
|
"meta": {"recordings"},
|
||||||
|
"format": {"json"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, lookupTimeout)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.baseURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("acoustid: build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Content-Encoding", "gzip")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
// The caller giving up is not the service failing.
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, bodyLimit))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: read body: %v", ErrUnavailable, err)
|
||||||
|
}
|
||||||
|
return parseLookup(resp.StatusCode, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wait blocks until minInterval has passed since the last request, then
|
||||||
|
// claims the slot.
|
||||||
|
func (c *Client) wait(ctx context.Context) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if d := time.Until(c.lastCall.Add(minInterval)); d > 0 {
|
||||||
|
t := time.NewTimer(d)
|
||||||
|
defer t.Stop()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-t.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.lastCall = time.Now()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func gzipForm(v url.Values) ([]byte, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
zw := gzip.NewWriter(&buf)
|
||||||
|
if _, err := zw.Write([]byte(v.Encode())); err != nil {
|
||||||
|
return nil, fmt.Errorf("acoustid: compress request: %w", err)
|
||||||
|
}
|
||||||
|
if err := zw.Close(); err != nil {
|
||||||
|
return nil, fmt.Errorf("acoustid: compress request: %w", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type lookupResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error *struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error"`
|
||||||
|
Results []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
Recordings []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Duration float64 `json:"duration"`
|
||||||
|
Artists []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"artists"`
|
||||||
|
} `json:"recordings"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLookup reads a lookup response. AcoustID reports its errors in the
|
||||||
|
// body with a code, alongside a 4xx or 5xx status, so the body is read first
|
||||||
|
// and the status only decides when the body says nothing usable.
|
||||||
|
func parseLookup(status int, raw []byte) ([]Candidate, error) {
|
||||||
|
var r lookupResponse
|
||||||
|
if err := json.Unmarshal(raw, &r); err != nil {
|
||||||
|
if status != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("%w: status %d", ErrUnavailable, status)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%w: unreadable response: %v", ErrUnavailable, err)
|
||||||
|
}
|
||||||
|
if r.Status != "ok" {
|
||||||
|
return nil, lookupError(status, r)
|
||||||
|
}
|
||||||
|
return candidates(r), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupError(status int, r lookupResponse) error {
|
||||||
|
if r.Error == nil {
|
||||||
|
return fmt.Errorf("%w: status %d", ErrUnavailable, status)
|
||||||
|
}
|
||||||
|
switch r.Error.Code {
|
||||||
|
case codeInvalidAPIKey, codeUnknownApplication:
|
||||||
|
return fmt.Errorf("%w: %s", ErrInvalidKey, r.Error.Message)
|
||||||
|
case codeInvalidFingerprint:
|
||||||
|
return fmt.Errorf("%w: %s", ErrInvalidFingerprint, r.Error.Message)
|
||||||
|
case codeServiceUnavailable, codeTooManyRequests:
|
||||||
|
return fmt.Errorf("%w: %s", ErrUnavailable, r.Error.Message)
|
||||||
|
}
|
||||||
|
if status >= http.StatusInternalServerError {
|
||||||
|
return fmt.Errorf("%w: %s", ErrUnavailable, r.Error.Message)
|
||||||
|
}
|
||||||
|
// Any other code is a request this client built wrongly. Retrying would
|
||||||
|
// repeat it, so it is named rather than folded into unavailable.
|
||||||
|
return fmt.Errorf("acoustid: error %d: %s", r.Error.Code, r.Error.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidates flattens the results into one entry per recording. The same
|
||||||
|
// recording can hang off several AcoustID results (one audio, fingerprinted
|
||||||
|
// from different sources); it keeps the best score it was seen with.
|
||||||
|
// Results with no linked recording are fingerprints AcoustID knows but nobody
|
||||||
|
// has tied to MusicBrainz, and carry nothing to fill.
|
||||||
|
func candidates(r lookupResponse) []Candidate {
|
||||||
|
out := []Candidate{}
|
||||||
|
at := map[string]int{}
|
||||||
|
for _, res := range r.Results {
|
||||||
|
for _, rec := range res.Recordings {
|
||||||
|
if rec.ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i, ok := at[rec.ID]; ok {
|
||||||
|
out[i].Score = max(out[i].Score, res.Score)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c := Candidate{
|
||||||
|
Recording: Recording{ID: rec.ID, Title: rec.Title, DurationSec: int(rec.Duration + 0.5)},
|
||||||
|
Score: res.Score,
|
||||||
|
}
|
||||||
|
for _, a := range rec.Artists {
|
||||||
|
c.Artists = append(c.Artists, a.Name)
|
||||||
|
}
|
||||||
|
at[rec.ID] = len(out)
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Best first, keeping AcoustID's order among equals.
|
||||||
|
slices.SortStableFunc(out, func(a, b Candidate) int { return cmp.Compare(b.Score, a.Score) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package acoustid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// docSample is the meta=recordings response printed in AcoustID's own
|
||||||
|
// documentation (https://acoustid.org/webservice), artists and release groups
|
||||||
|
// as given.
|
||||||
|
const docSample = `{
|
||||||
|
"status": "ok",
|
||||||
|
"results": [{
|
||||||
|
"score": 1.0,
|
||||||
|
"id": "9ff43b6a-4f16-427c-93c2-92307ca505e0",
|
||||||
|
"recordings": [{
|
||||||
|
"duration": 639,
|
||||||
|
"releasegroups": [{"type": "Album", "id": "ddaa2d4d-314e-3e7c-b1d0-f6d207f5aa2f", "title": "Before the Dawn Heals Us"}],
|
||||||
|
"title": "Lower Your Eyelids to Die With the Sun",
|
||||||
|
"id": "cd2e7c47-16f5-46c6-a37c-a1eb7bf599ff",
|
||||||
|
"artists": [{"id": "6d7b7cd4-254b-4c25-83f6-dd20f98ceacd", "name": "M83"}]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}`
|
||||||
|
|
||||||
|
// severalSample is the shape the worker has to disambiguate: one audio linked
|
||||||
|
// to two recordings (an album and a single release), the album recording
|
||||||
|
// reached again through a weaker second result, and a third result that
|
||||||
|
// AcoustID knows but nobody has tied to MusicBrainz.
|
||||||
|
const severalSample = `{
|
||||||
|
"status": "ok",
|
||||||
|
"results": [
|
||||||
|
{"score": 0.71, "id": "a2", "recordings": [{"id": "rec-album", "title": "Song", "duration": 200.4}]},
|
||||||
|
{"score": 0.93, "id": "a1", "recordings": [
|
||||||
|
{"id": "rec-album", "title": "Song", "duration": 200.4, "artists": [{"name": "A"}, {"name": "B"}]},
|
||||||
|
{"id": "rec-single", "title": "Song (radio edit)", "duration": 181}
|
||||||
|
]},
|
||||||
|
{"score": 0.88, "id": "a3"}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
func TestParseLookup(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
want []Candidate
|
||||||
|
}{
|
||||||
|
{"single match", docSample, []Candidate{{
|
||||||
|
Recording: Recording{
|
||||||
|
ID: "cd2e7c47-16f5-46c6-a37c-a1eb7bf599ff", Title: "Lower Your Eyelids to Die With the Sun",
|
||||||
|
DurationSec: 639, Artists: []string{"M83"},
|
||||||
|
},
|
||||||
|
Score: 1.0,
|
||||||
|
}}},
|
||||||
|
{"several recordings", severalSample, []Candidate{
|
||||||
|
{Recording: Recording{ID: "rec-album", Title: "Song", DurationSec: 200}, Score: 0.93},
|
||||||
|
{Recording: Recording{ID: "rec-single", Title: "Song (radio edit)", DurationSec: 181}, Score: 0.93},
|
||||||
|
}},
|
||||||
|
// Choosing a threshold is the worker's; the parser carries the score.
|
||||||
|
{"below threshold", `{"status":"ok","results":[{"score":0.31,"id":"x","recordings":[{"id":"rec"}]}]}`,
|
||||||
|
[]Candidate{{Recording: Recording{ID: "rec"}, Score: 0.31}}},
|
||||||
|
{"no results", `{"status":"ok","results":[]}`, []Candidate{}},
|
||||||
|
{"results with no recordings", `{"status":"ok","results":[{"score":0.99,"id":"x"}]}`, []Candidate{}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, err := parseLookup(http.StatusOK, []byte(c.body))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s: err = %v", c.name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !sameCandidates(got, c.want) {
|
||||||
|
t.Errorf("%s:\n got %+v\nwant %+v", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first result's recording is the album one with both artists; the
|
||||||
|
// weaker repeat must not replace its fields, only fail to lower its score.
|
||||||
|
func TestParseLookup_RepeatedRecordingKeepsBestScoreAndFirstFields(t *testing.T) {
|
||||||
|
got, err := parseLookup(http.StatusOK, []byte(severalSample))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got[0].ID != "rec-album" || got[0].Score != 0.93 {
|
||||||
|
t.Errorf("album recording = %+v, want score 0.93", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLookup_Errors(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"invalid key", 400, `{"status":"error","error":{"code":4,"message":"invalid API key"}}`, ErrInvalidKey},
|
||||||
|
{"unknown application", 400, `{"status":"error","error":{"code":17,"message":"unknown application"}}`, ErrInvalidKey},
|
||||||
|
{"bad fingerprint", 400, `{"status":"error","error":{"code":3,"message":"invalid fingerprint"}}`, ErrInvalidFingerprint},
|
||||||
|
{"rate limited", 429, `{"status":"error","error":{"code":14,"message":"rate limit (3.000000 requests per second) exceeded, try again later"}}`, ErrUnavailable},
|
||||||
|
{"down", 503, `{"status":"error","error":{"code":13,"message":"service currently unavailable, try again later"}}`, ErrUnavailable},
|
||||||
|
{"internal error", 500, `{"status":"error","error":{"code":5,"message":"internal error"}}`, ErrUnavailable},
|
||||||
|
{"proxy page", 502, `<html>Bad Gateway</html>`, ErrUnavailable},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
_, err := parseLookup(c.status, []byte(c.body))
|
||||||
|
if !errors.Is(err, c.want) {
|
||||||
|
t.Errorf("%s: err = %v, want %v", c.name, err, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A code this client caused (here a missing parameter) is neither the
|
||||||
|
// key nor the service, so it must not read as either: retrying repeats it.
|
||||||
|
_, err := parseLookup(400, []byte(`{"status":"error","error":{"code":2,"message":"missing required parameter \"fingerprint\""}}`))
|
||||||
|
if err == nil || errors.Is(err, ErrUnavailable) || errors.Is(err, ErrInvalidKey) || !strings.Contains(err.Error(), "missing required parameter") {
|
||||||
|
t.Errorf("missing parameter: err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The request is what AcoustID documents: a gzip form POST carrying the key,
|
||||||
|
// whole-second duration, fingerprint and meta=recordings.
|
||||||
|
func TestLookup_SendsACompressedForm(t *testing.T) {
|
||||||
|
var form url.Values
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost || r.Header.Get("Content-Encoding") != "gzip" {
|
||||||
|
t.Errorf("request %s with Content-Encoding %q, want a gzip POST", r.Method, r.Header.Get("Content-Encoding"))
|
||||||
|
}
|
||||||
|
zr, err := gzip.NewReader(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("body is not gzip: %v", err)
|
||||||
|
}
|
||||||
|
raw, _ := io.ReadAll(zr)
|
||||||
|
form, _ = url.ParseQuery(string(raw))
|
||||||
|
_, _ = io.WriteString(w, docSample)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
got, err := New(srv.URL).Lookup(context.Background(), "key123", "AQADtE", 241)
|
||||||
|
if err != nil || len(got) != 1 {
|
||||||
|
t.Fatalf("Lookup = %+v, %v", got, err)
|
||||||
|
}
|
||||||
|
for k, want := range map[string]string{
|
||||||
|
"client": "key123", "duration": "241", "fingerprint": "AQADtE", "meta": "recordings", "format": "json",
|
||||||
|
} {
|
||||||
|
if form.Get(k) != want {
|
||||||
|
t.Errorf("form %s = %q, want %q", k, form.Get(k), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookup_DeadlineIsUnavailable(t *testing.T) {
|
||||||
|
release := make(chan struct{})
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { <-release }))
|
||||||
|
defer srv.Close()
|
||||||
|
defer close(release)
|
||||||
|
|
||||||
|
c := New(srv.URL)
|
||||||
|
c.http.Timeout = 50 * time.Millisecond
|
||||||
|
_, err := c.Lookup(context.Background(), "k", "f", 1)
|
||||||
|
if !errors.Is(err, ErrUnavailable) {
|
||||||
|
t.Errorf("err = %v, want ErrUnavailable", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cancelled caller is reported as such, never as the service being down.
|
||||||
|
func TestLookup_CancelledCallerIsNotUnavailable(t *testing.T) {
|
||||||
|
release := make(chan struct{})
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { <-release }))
|
||||||
|
defer srv.Close()
|
||||||
|
defer close(release)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
_, err := New(srv.URL).Lookup(ctx, "k", "f", 1)
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, ErrUnavailable) {
|
||||||
|
t.Errorf("err = %v, want the caller's own deadline", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookup_KeepsToTheRateLimit(t *testing.T) {
|
||||||
|
var at []time.Time
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
at = append(at, time.Now())
|
||||||
|
_, _ = io.WriteString(w, `{"status":"ok","results":[]}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := New(srv.URL)
|
||||||
|
for range 3 {
|
||||||
|
if _, err := c.Lookup(context.Background(), "k", "f", 1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 1; i < len(at); i++ {
|
||||||
|
// A little slack for the server's own clock reading.
|
||||||
|
if gap := at[i].Sub(at[i-1]); gap < minInterval-10*time.Millisecond {
|
||||||
|
t.Errorf("requests %d and %d were %s apart, want at least %s", i-1, i, gap, minInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameCandidates(a, b []Candidate) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i].ID != b[i].ID || a[i].Title != b[i].Title || a[i].DurationSec != b[i].DurationSec ||
|
||||||
|
a[i].Score != b[i].Score || strings.Join(a[i].Artists, "|") != strings.Join(b[i].Artists, "|") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AcoustID recording-id lookup, admin side (M401 #3922).
|
||||||
|
|
||||||
|
// acoustIDUnsettledLimit caps the admin list of tracks a lookup could not
|
||||||
|
// settle. It is a list to read, not to page through.
|
||||||
|
const acoustIDUnsettledLimit = 200
|
||||||
|
|
||||||
|
// acoustIDSettingsResp never carries the key, only whether one is stored.
|
||||||
|
type acoustIDSettingsResp struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
MinScore float32 `json:"min_score"`
|
||||||
|
APIKeySet bool `json:"api_key_set"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func acoustIDSettingsRespOf(s library.AcoustIDSettings) acoustIDSettingsResp {
|
||||||
|
return acoustIDSettingsResp{Enabled: s.Enabled, MinScore: s.MinScore, APIKeySet: s.APIKey != ""}
|
||||||
|
}
|
||||||
|
|
||||||
|
type acoustIDStatusResp struct {
|
||||||
|
Running bool `json:"running"`
|
||||||
|
LastPassAt *time.Time `json:"last_pass_at"`
|
||||||
|
// Problem is why the last pass stopped short, in words for the card.
|
||||||
|
Problem string `json:"problem"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// acoustIDCoverageResp: from_tag + from_acoustid + the tracks with no MBID =
|
||||||
|
// total. ambiguous, no_match and failed are lookup states of tracks with no
|
||||||
|
// MBID; pending is what the worker still has to look at.
|
||||||
|
type acoustIDCoverageResp struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
FromTag int64 `json:"from_tag"`
|
||||||
|
FromAcoustID int64 `json:"from_acoustid"`
|
||||||
|
Ambiguous int64 `json:"ambiguous"`
|
||||||
|
NoMatch int64 `json:"no_match"`
|
||||||
|
Failed int64 `json:"failed"`
|
||||||
|
Pending int64 `json:"pending"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type acoustIDResp struct {
|
||||||
|
Settings acoustIDSettingsResp `json:"settings"`
|
||||||
|
// Ready is whether the worker will call AcoustID at all: switched on
|
||||||
|
// and holding a key.
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
Status acoustIDStatusResp `json:"status"`
|
||||||
|
Coverage acoustIDCoverageResp `json:"coverage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetAcoustID implements GET /api/admin/library/acoustid: the settings,
|
||||||
|
// what the worker is doing, and how much of the library has an MBID by source.
|
||||||
|
func (h *handlers) handleGetAcoustID(w http.ResponseWriter, r *http.Request) {
|
||||||
|
row, err := library.AcoustIDCoverage(r.Context(), h.pool)
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin: get acoustid coverage", apierror.InternalMsg("lookup failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := h.acoustIDLookup.Settings().Get()
|
||||||
|
st := h.acoustIDLookup.Status()
|
||||||
|
resp := acoustIDResp{
|
||||||
|
Settings: acoustIDSettingsRespOf(cfg),
|
||||||
|
Ready: cfg.Ready(),
|
||||||
|
Status: acoustIDStatusResp{Running: st.Running, Problem: st.Problem},
|
||||||
|
Coverage: acoustIDCoverageResp{
|
||||||
|
Total: row.Total,
|
||||||
|
FromTag: row.FromTag,
|
||||||
|
FromAcoustID: row.FromAcoustid,
|
||||||
|
Ambiguous: row.Ambiguous,
|
||||||
|
NoMatch: row.NoMatch,
|
||||||
|
Failed: row.Failed,
|
||||||
|
Pending: row.Pending,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if !st.LastPassAt.IsZero() {
|
||||||
|
at := st.LastPassAt.UTC()
|
||||||
|
resp.Status.LastPassAt = &at
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// acoustIDSettingsReq is the PUT body. api_key left out keeps the stored key;
|
||||||
|
// an empty string clears it.
|
||||||
|
type acoustIDSettingsReq struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
MinScore float32 `json:"min_score"`
|
||||||
|
APIKey *string `json:"api_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateAcoustIDSettings implements PUT /api/admin/library/acoustid-settings.
|
||||||
|
func (h *handlers) handleUpdateAcoustIDSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req acoustIDSettingsReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saved, err := h.acoustIDLookup.Settings().Set(r.Context(), library.AcoustIDSettingsUpdate{
|
||||||
|
Enabled: req.Enabled, MinScore: req.MinScore, APIKey: req.APIKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, library.ErrAcoustIDSettingOutOfRange) {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_setting", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeErrWithLog(w, h.logger, "admin acoustid settings: update failed", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, acoustIDSettingsRespOf(saved))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRunAcoustIDLookup implements POST /api/admin/library/acoustid/run:
|
||||||
|
// start a pass now rather than at the next tick. 409 when the lookup is off
|
||||||
|
// or has no key, so the card can say so rather than appear to do nothing.
|
||||||
|
func (h *handlers) handleRunAcoustIDLookup(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
if !h.acoustIDLookup.Settings().Get().Ready() {
|
||||||
|
writeErr(w, apierror.Conflict("not_ready",
|
||||||
|
"Switch the AcoustID lookup on and add an API key first."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.acoustIDLookup.Kick()
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
type acoustIDUnsettledResp struct {
|
||||||
|
TrackID string `json:"track_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
Album string `json:"album"`
|
||||||
|
FilePath string `json:"file_path"`
|
||||||
|
State string `json:"state"`
|
||||||
|
BestScore *float32 `json:"best_score"`
|
||||||
|
Candidates int32 `json:"candidates"`
|
||||||
|
Detail *string `json:"detail"`
|
||||||
|
LookedUpAt time.Time `json:"looked_up_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListUnsettledAcoustID implements GET /api/admin/library/acoustid/unsettled:
|
||||||
|
// the tracks a lookup could not settle (ambiguous or failed), so they can be
|
||||||
|
// found and tagged by hand.
|
||||||
|
func (h *handlers) handleListUnsettledAcoustID(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := dbq.New(h.pool).ListUnsettledAcoustIDLookups(r.Context(), acoustIDUnsettledLimit)
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin: list unsettled acoustid lookups", apierror.InternalMsg("lookup failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]acoustIDUnsettledResp, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, acoustIDUnsettledResp{
|
||||||
|
TrackID: uuidToString(row.ID),
|
||||||
|
Title: row.Title,
|
||||||
|
Artist: row.ArtistName,
|
||||||
|
Album: row.AlbumTitle,
|
||||||
|
FilePath: row.FilePath,
|
||||||
|
State: row.State,
|
||||||
|
BestScore: row.BestScore,
|
||||||
|
Candidates: row.Candidates,
|
||||||
|
Detail: row.Detail,
|
||||||
|
LookedUpAt: row.LookedUpAt.Time.UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
)
|
)
|
||||||
|
|
||||||
// coverageRollupResp is the wire shape for GET /api/admin/library/coverage.
|
// coverageRollupResp is the wire shape for GET /api/admin/library/coverage.
|
||||||
@@ -36,3 +38,46 @@ func (h *handlers) handleGetLibraryCoverage(w http.ResponseWriter, r *http.Reque
|
|||||||
PendingNoMbid: row.PendingNoMbid,
|
PendingNoMbid: row.PendingNoMbid,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fingerprintCoverageResp is the wire shape for GET /api/admin/library/fingerprints.
|
||||||
|
// fingerprinted + rejected + pending = total. Missing tracks are not counted:
|
||||||
|
// there is no file to fingerprint. Enabled travels with the counts because with
|
||||||
|
// fingerprinting off (#3913) pending never shrinks, and a gauge that implies
|
||||||
|
// progress would be promising work nothing is doing.
|
||||||
|
type fingerprintCoverageResp struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Fingerprinted int64 `json:"fingerprinted"`
|
||||||
|
Rejected int64 `json:"rejected"`
|
||||||
|
Pending int64 `json:"pending"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// fingerprintCoverage reads the gauge against the current settings: a print at
|
||||||
|
// another length counts as pending, because the backfill will re-derive it.
|
||||||
|
func (h *handlers) fingerprintCoverage(ctx context.Context) (fingerprintCoverageResp, error) {
|
||||||
|
cfg := h.fingerprintSettings.Get()
|
||||||
|
row, err := library.FingerprintCoverage(ctx, h.pool, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fingerprintCoverageResp{}, err
|
||||||
|
}
|
||||||
|
return fingerprintCoverageResp{
|
||||||
|
Total: row.Total,
|
||||||
|
Fingerprinted: row.Fingerprinted,
|
||||||
|
Rejected: row.Rejected,
|
||||||
|
Pending: row.Pending,
|
||||||
|
Enabled: cfg.Enabled,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetFingerprintCoverage implements GET /api/admin/library/fingerprints:
|
||||||
|
// how far the fingerprint backfill (#3908) has got. The backfill is its own
|
||||||
|
// worker spanning many passes, with no scan run to attach a tally to, so its
|
||||||
|
// progress is read live here. Always 200; zeros on an empty library.
|
||||||
|
func (h *handlers) handleGetFingerprintCoverage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cov, err := h.fingerprintCoverage(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin: get fingerprint coverage", apierror.InternalMsg("lookup failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, cov)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
|
)
|
||||||
|
|
||||||
|
// duplicateMemberView is one copy in a proposed duplicate group. LikeCount and
|
||||||
|
// PlayCount span every user: the report is admin-only, and what a copy carries
|
||||||
|
// is the fact the operator weighs when choosing which to keep.
|
||||||
|
type duplicateMemberView struct {
|
||||||
|
TrackID string `json:"track_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
AlbumID string `json:"album_id"`
|
||||||
|
AlbumTitle string `json:"album_title"`
|
||||||
|
FilePath string `json:"file_path"`
|
||||||
|
FileFormat string `json:"file_format"`
|
||||||
|
FileSize int64 `json:"file_size"`
|
||||||
|
DurationSec int32 `json:"duration_sec"`
|
||||||
|
AddedAt string `json:"added_at"`
|
||||||
|
LikeCount int64 `json:"like_count"`
|
||||||
|
PlayCount int64 `json:"play_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// duplicateGroupView is one proposal. SurvivorTrackID and SurvivorReason are
|
||||||
|
// the copy the report proposes keeping and the rule that chose it
|
||||||
|
// (library.ProposeSurvivor) — a default the merge (#3911) lets the operator
|
||||||
|
// override.
|
||||||
|
type duplicateGroupView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
WorstBitErrorRate *float32 `json:"worst_bit_error_rate"`
|
||||||
|
DetectedAt string `json:"detected_at"`
|
||||||
|
SurvivorTrackID string `json:"survivor_track_id"`
|
||||||
|
SurvivorReason string `json:"survivor_reason"`
|
||||||
|
Members []duplicateMemberView `json:"members"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// duplicateSweepView is the latest sweep. State is "never" when none has run,
|
||||||
|
// which is what lets the page tell an empty report apart from a sweep that
|
||||||
|
// found nothing.
|
||||||
|
type duplicateSweepView struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
StartedAt *string `json:"started_at"`
|
||||||
|
FinishedAt *string `json:"finished_at"`
|
||||||
|
Candidates *int32 `json:"candidates"`
|
||||||
|
GroupsFound *int32 `json:"groups_found"`
|
||||||
|
OversizeClusters *int32 `json:"oversize_clusters"`
|
||||||
|
ErrorMessage *string `json:"error_message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminDuplicatesResponse is the paged report. Total counts groups.
|
||||||
|
type adminDuplicatesResponse struct {
|
||||||
|
Sweep duplicateSweepView `json:"sweep"`
|
||||||
|
Fingerprints fingerprintCoverageResp `json:"fingerprints"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
Groups []duplicateGroupView `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListDuplicates implements GET /api/admin/library/duplicates (#3912).
|
||||||
|
//
|
||||||
|
// Read-only. The sweep's state and the fingerprint backfill's progress travel
|
||||||
|
// with the groups because an empty report means three different things — still
|
||||||
|
// fingerprinting, never swept, or swept and clean — and the page has to say which.
|
||||||
|
func (h *handlers) handleListDuplicates(w http.ResponseWriter, r *http.Request) {
|
||||||
|
limit, offset, err := parsePaging(r.URL.Query())
|
||||||
|
if err != nil {
|
||||||
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_paging")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
|
||||||
|
sweep := duplicateSweepView{State: "never"}
|
||||||
|
last, err := q.GetLatestDuplicateSweep(ctx)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
sweep = duplicateSweepViewOf(last)
|
||||||
|
case !errors.Is(err, pgx.ErrNoRows):
|
||||||
|
h.logger.Error("admin: latest duplicate sweep", "err", err)
|
||||||
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cov, err := h.fingerprintCoverage(ctx)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin: fingerprint coverage", "err", err)
|
||||||
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, err := q.CountPendingDuplicateGroups(ctx)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin: count duplicate groups", "err", err)
|
||||||
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := q.ListPendingDuplicateGroupMembers(ctx, dbq.ListPendingDuplicateGroupMembersParams{
|
||||||
|
PageLimit: int32(limit), PageOffset: int32(offset),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin: list duplicate groups", "err", err)
|
||||||
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, adminDuplicatesResponse{
|
||||||
|
Sweep: sweep,
|
||||||
|
Fingerprints: cov,
|
||||||
|
Total: total,
|
||||||
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
Groups: foldDuplicateGroups(rows),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func duplicateSweepViewOf(s dbq.DuplicateSweep) duplicateSweepView {
|
||||||
|
v := duplicateSweepView{
|
||||||
|
State: "running",
|
||||||
|
Candidates: s.Candidates,
|
||||||
|
GroupsFound: s.GroupsFound,
|
||||||
|
OversizeClusters: s.OversizeClusters,
|
||||||
|
ErrorMessage: s.ErrorMessage,
|
||||||
|
}
|
||||||
|
started := formatTimestamp(s.StartedAt)
|
||||||
|
v.StartedAt = &started
|
||||||
|
if s.FinishedAt.Valid {
|
||||||
|
finished := formatTimestamp(s.FinishedAt)
|
||||||
|
v.FinishedAt = &finished
|
||||||
|
v.State = "finished"
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// foldDuplicateGroups folds the one-row-per-member query result into groups and
|
||||||
|
// proposes each group's survivor. It relies on the query ordering members of a
|
||||||
|
// group together, so a run-length fold is enough and the page order holds.
|
||||||
|
func foldDuplicateGroups(rows []dbq.ListPendingDuplicateGroupMembersRow) []duplicateGroupView {
|
||||||
|
groups := make([]duplicateGroupView, 0, 8)
|
||||||
|
var candidates [][]library.SurvivorCandidate
|
||||||
|
for _, row := range rows {
|
||||||
|
id := uuidToString(row.GroupID)
|
||||||
|
if n := len(groups); n == 0 || groups[n-1].ID != id {
|
||||||
|
groups = append(groups, duplicateGroupView{
|
||||||
|
ID: id,
|
||||||
|
Tier: row.Tier,
|
||||||
|
WorstBitErrorRate: row.WorstBitErrorRate,
|
||||||
|
DetectedAt: formatTimestamp(row.DetectedAt),
|
||||||
|
})
|
||||||
|
candidates = append(candidates, nil)
|
||||||
|
}
|
||||||
|
n := len(groups) - 1
|
||||||
|
trackID := uuidToString(row.TrackID)
|
||||||
|
groups[n].Members = append(groups[n].Members, duplicateMemberView{
|
||||||
|
TrackID: trackID,
|
||||||
|
Title: row.Title,
|
||||||
|
ArtistName: row.ArtistName,
|
||||||
|
AlbumID: uuidToString(row.AlbumID),
|
||||||
|
AlbumTitle: row.AlbumTitle,
|
||||||
|
FilePath: row.FilePath,
|
||||||
|
FileFormat: row.FileFormat,
|
||||||
|
FileSize: row.FileSize,
|
||||||
|
DurationSec: row.DurationMs / 1000,
|
||||||
|
AddedAt: formatTimestamp(row.AddedAt),
|
||||||
|
LikeCount: row.LikeCount,
|
||||||
|
PlayCount: row.PlayCount,
|
||||||
|
})
|
||||||
|
candidates[n] = append(candidates[n], library.SurvivorCandidate{
|
||||||
|
TrackID: trackID, FileFormat: row.FileFormat, FileSize: row.FileSize, AddedAt: row.AddedAt.Time,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for i := range groups {
|
||||||
|
groups[i].SurvivorTrackID, groups[i].SurvivorReason = library.ProposeSurvivor(candidates[i])
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRunDuplicateSweep implements POST /api/admin/library/duplicates/sweep:
|
||||||
|
// 202 when a sweep starts, 409 sweep_in_progress when one is already running.
|
||||||
|
// The sweep outlives the request, so it runs on a background context, as
|
||||||
|
// handleTriggerScan's scan does.
|
||||||
|
func (h *handlers) handleRunDuplicateSweep(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
// Runs whatever the sweep interval says: the interval paces the automatic
|
||||||
|
// sweep, and an operator pressing the button has already decided.
|
||||||
|
started, err := library.TryStartDuplicateSweep(
|
||||||
|
context.Background(), h.pool, h.logger.With("source", "manual"), h.fingerprintSettings.Get(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin: start duplicate sweep", "err", err)
|
||||||
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !started {
|
||||||
|
writeAdminJSONErr(w, http.StatusConflict, "sweep_in_progress")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]bool{"started": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDismissDuplicateGroup implements POST
|
||||||
|
// /api/admin/library/duplicates/{id}/dismiss: "these are not duplicates". The
|
||||||
|
// sweep keeps the dismissal and will not propose that set of tracks again. 404
|
||||||
|
// duplicate_group_not_pending when the group was already resolved or is gone.
|
||||||
|
func (h *handlers) handleDismissDuplicateGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if !ok {
|
||||||
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := dbq.New(h.pool).DismissDuplicateGroup(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin: dismiss duplicate group", "err", err)
|
||||||
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
writeAdminJSONErr(w, http.StatusNotFound, "duplicate_group_not_pending")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "dismissed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeDuplicateRequest chooses the copy to keep. An empty survivor_track_id
|
||||||
|
// keeps the report's proposal.
|
||||||
|
type mergeDuplicateRequest struct {
|
||||||
|
SurvivorTrackID string `json:"survivor_track_id"`
|
||||||
|
Unmonitor bool `json:"unmonitor"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeDuplicateResponse reports what the merge removed. RemovedPaths are files
|
||||||
|
// deleted from disk; the operator reads them to know exactly what went.
|
||||||
|
type mergeDuplicateResponse struct {
|
||||||
|
SurvivorTrackID string `json:"survivor_track_id"`
|
||||||
|
RemovedPaths []string `json:"removed_paths"`
|
||||||
|
LidarrUnmonitorFailed *bool `json:"lidarr_unmonitor_failed,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeRequestBodyLimit bounds the request body. It holds one id and a flag.
|
||||||
|
const mergeRequestBodyLimit = 1 << 16
|
||||||
|
|
||||||
|
// handleMergeDuplicateGroup implements POST /api/admin/library/duplicates/{id}/merge
|
||||||
|
// (#3911): keep one copy, move the others' likes, plays and playlist entries onto
|
||||||
|
// it, and delete the others' files and rows.
|
||||||
|
//
|
||||||
|
// Errors:
|
||||||
|
// - 409 library_not_writable / 500 file_delete_failed when a file could not be
|
||||||
|
// removed — nothing was changed (fileRemoveAPIError)
|
||||||
|
// - 404 duplicate_group_not_pending when the group was already resolved
|
||||||
|
// - 400 survivor_not_in_group, invalid_id, invalid_body
|
||||||
|
func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
admin, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
groupID, ok := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if !ok {
|
||||||
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body mergeDuplicateRequest
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, mergeRequestBodyLimit)).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var survivorID pgtype.UUID // invalid: keep the proposal
|
||||||
|
if body.SurvivorTrackID != "" {
|
||||||
|
if survivorID, ok = parseUUID(body.SurvivorTrackID); !ok {
|
||||||
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res, unmonitorFailed, err := h.tracks.MergeDuplicates(r.Context(), groupID, survivorID, admin.ID, body.Unmonitor)
|
||||||
|
if err != nil {
|
||||||
|
if apiErr, ok := fileRemoveAPIError(err); ok {
|
||||||
|
logFileRemoveFailure(h.logger, apiErr, "group_id", uuidToString(groupID))
|
||||||
|
writeErr(w, apiErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, library.ErrDuplicateGroupNotPending):
|
||||||
|
writeAdminJSONErr(w, http.StatusNotFound, "duplicate_group_not_pending")
|
||||||
|
case errors.Is(err, library.ErrSurvivorNotInGroup):
|
||||||
|
writeAdminJSONErr(w, http.StatusBadRequest, "survivor_not_in_group")
|
||||||
|
default:
|
||||||
|
h.logger.Error("admin: merge duplicate group", "group_id", uuidToString(groupID), "err", err)
|
||||||
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := mergeDuplicateResponse{
|
||||||
|
SurvivorTrackID: uuidToString(res.Survivor.TrackID),
|
||||||
|
RemovedPaths: make([]string, 0, len(res.Removed)),
|
||||||
|
}
|
||||||
|
for _, c := range res.Removed {
|
||||||
|
resp.RemovedPaths = append(resp.RemovedPaths, c.FilePath)
|
||||||
|
}
|
||||||
|
if body.Unmonitor && unmonitorFailed {
|
||||||
|
failed := true
|
||||||
|
resp.LidarrUnmonitorFailed = &failed
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func dupUUID(b byte) pgtype.UUID {
|
||||||
|
var u pgtype.UUID
|
||||||
|
u.Bytes[15] = b
|
||||||
|
u.Valid = true
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
func dupTS(t time.Time) pgtype.Timestamptz { return pgtype.Timestamptz{Time: t, Valid: true} }
|
||||||
|
|
||||||
|
// Rows arrive one per member, members of a group together. The fold must keep
|
||||||
|
// groups apart, keep the query's order, and propose each group's survivor from
|
||||||
|
// its own members only.
|
||||||
|
func TestFoldDuplicateGroups(t *testing.T) {
|
||||||
|
older := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
newer := older.Add(48 * time.Hour)
|
||||||
|
ber := float32(0.04)
|
||||||
|
rows := []dbq.ListPendingDuplicateGroupMembersRow{
|
||||||
|
// Group 1: identical audio, sizes tie, the older copy should be kept.
|
||||||
|
{GroupID: dupUUID(1), Tier: "exact", DetectedAt: dupTS(newer), TrackID: dupUUID(10),
|
||||||
|
Title: "WWW", FileFormat: "mp3", FileSize: 6_900_000, DurationMs: 215_400, AddedAt: dupTS(newer), PlayCount: 3},
|
||||||
|
{GroupID: dupUUID(1), Tier: "exact", DetectedAt: dupTS(newer), TrackID: dupUUID(11),
|
||||||
|
Title: "WWW", FileFormat: "mp3", FileSize: 6_900_000, DurationMs: 215_400, AddedAt: dupTS(older), LikeCount: 1},
|
||||||
|
// Group 2: the same recording, FLAC against MP3.
|
||||||
|
{GroupID: dupUUID(2), Tier: "acoustic", WorstBitErrorRate: &ber, DetectedAt: dupTS(older), TrackID: dupUUID(20),
|
||||||
|
Title: "Lovesick", FileFormat: "mp3", FileSize: 9_000_000, DurationMs: 198_000, AddedAt: dupTS(older)},
|
||||||
|
{GroupID: dupUUID(2), Tier: "acoustic", WorstBitErrorRate: &ber, DetectedAt: dupTS(older), TrackID: dupUUID(21),
|
||||||
|
Title: "Lovesick", FileFormat: "flac", FileSize: 30_000_000, DurationMs: 198_000, AddedAt: dupTS(newer)},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := foldDuplicateGroups(rows)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("folded %d groups, want 2", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
g1, g2 := got[0], got[1]
|
||||||
|
if g1.ID != uuidToString(dupUUID(1)) || len(g1.Members) != 2 || g1.WorstBitErrorRate != nil {
|
||||||
|
t.Fatalf("group 1 = %+v, want the exact pair with no score", g1)
|
||||||
|
}
|
||||||
|
if g1.SurvivorTrackID != uuidToString(dupUUID(11)) || g1.SurvivorReason != "in the library longest" {
|
||||||
|
t.Errorf("group 1 survivor = (%s, %q), want the older copy", g1.SurvivorTrackID, g1.SurvivorReason)
|
||||||
|
}
|
||||||
|
if g1.Members[0].DurationSec != 215 || g1.Members[0].PlayCount != 3 || g1.Members[1].LikeCount != 1 {
|
||||||
|
t.Errorf("group 1 members lost their facts: %+v", g1.Members)
|
||||||
|
}
|
||||||
|
|
||||||
|
if g2.Tier != "acoustic" || g2.WorstBitErrorRate == nil || *g2.WorstBitErrorRate != ber {
|
||||||
|
t.Fatalf("group 2 = %+v, want the acoustic pair with its score", g2)
|
||||||
|
}
|
||||||
|
// Chosen from group 2's own members: a survivor leaking across groups is
|
||||||
|
// exactly what a wrong fold boundary would produce.
|
||||||
|
if g2.SurvivorTrackID != uuidToString(dupUUID(21)) || g2.SurvivorReason != "lossless (flac)" {
|
||||||
|
t.Errorf("group 2 survivor = (%s, %q), want the FLAC copy", g2.SurvivorTrackID, g2.SurvivorReason)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fingerprintSettingsBody is the wire shape for GET and PUT
|
||||||
|
// /api/admin/library/fingerprint-settings (M400 #3913). The threshold travels as
|
||||||
|
// the bit-error rate the matcher uses; the card presents it as a match percentage.
|
||||||
|
type fingerprintSettingsBody struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
ChromaprintLengthSec int32 `json:"chromaprint_length_sec"`
|
||||||
|
AcousticMaxBitErrorRate float64 `json:"acoustic_max_bit_error_rate"`
|
||||||
|
BackfillConcurrency int32 `json:"backfill_concurrency"`
|
||||||
|
SweepIntervalHours int32 `json:"sweep_interval_hours"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func fingerprintSettingsBodyOf(s library.FingerprintSettings) fingerprintSettingsBody {
|
||||||
|
return fingerprintSettingsBody{
|
||||||
|
Enabled: s.Enabled,
|
||||||
|
ChromaprintLengthSec: s.ChromaprintLengthSec,
|
||||||
|
AcousticMaxBitErrorRate: s.AcousticMaxBitErrorRate,
|
||||||
|
BackfillConcurrency: s.BackfillConcurrency,
|
||||||
|
SweepIntervalHours: s.SweepIntervalHours,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetFingerprintSettings implements GET /api/admin/library/fingerprint-settings.
|
||||||
|
func (h *handlers) handleGetFingerprintSettings(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, fingerprintSettingsBodyOf(h.fingerprintSettings.Get()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateFingerprintSettings implements PUT /api/admin/library/fingerprint-settings.
|
||||||
|
//
|
||||||
|
// A whole-row write. A body that leaves a field out decodes it as zero, which no
|
||||||
|
// field accepts, so a partial save is refused rather than zeroing what it omitted.
|
||||||
|
// The saved settings reach the scanner and both workers at once: they share the
|
||||||
|
// service instance.
|
||||||
|
func (h *handlers) handleUpdateFingerprintSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req fingerprintSettingsBody
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saved, err := h.fingerprintSettings.Set(r.Context(), library.FingerprintSettings{
|
||||||
|
Enabled: req.Enabled,
|
||||||
|
ChromaprintLengthSec: req.ChromaprintLengthSec,
|
||||||
|
AcousticMaxBitErrorRate: req.AcousticMaxBitErrorRate,
|
||||||
|
BackfillConcurrency: req.BackfillConcurrency,
|
||||||
|
SweepIntervalHours: req.SweepIntervalHours,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// Validation mirrors migration 0061's CHECKs and names the field.
|
||||||
|
if errors.Is(err, library.ErrFingerprintSettingOutOfRange) {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_setting", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeErrWithLog(w, h.logger, "admin fingerprint settings: update failed", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, fingerprintSettingsBodyOf(saved))
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetFingerprintSettings_ServesDefaultsWithoutAService(t *testing.T) {
|
||||||
|
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.handleGetFingerprintSettings(rec, httptest.NewRequest(http.MethodGet, "/api/admin/library/fingerprint-settings", nil))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
var got fingerprintSettingsBody
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if want := fingerprintSettingsBodyOf(library.DefaultFingerprintSettings); got != want {
|
||||||
|
t.Fatalf("body = %+v, want the defaults %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateFingerprintSettings_Rejects(t *testing.T) {
|
||||||
|
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||||
|
for name, tc := range map[string]struct {
|
||||||
|
body string
|
||||||
|
code string
|
||||||
|
mentions string
|
||||||
|
}{
|
||||||
|
"a value out of range, naming the field": {
|
||||||
|
body: `{"enabled":true,"chromaprint_length_sec":5,"acoustic_max_bit_error_rate":0.15,"backfill_concurrency":2,"sweep_interval_hours":1}`,
|
||||||
|
code: "invalid_setting",
|
||||||
|
mentions: "chromaprint_length_sec",
|
||||||
|
},
|
||||||
|
// A partial body would otherwise zero every field it left out.
|
||||||
|
"a body missing fields": {body: `{"enabled":false}`, code: "invalid_setting"},
|
||||||
|
"malformed JSON": {body: `{"enabled":`, code: "invalid_body"},
|
||||||
|
} {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.handleUpdateFingerprintSettings(rec, httptest.NewRequest(
|
||||||
|
http.MethodPut, "/api/admin/library/fingerprint-settings", strings.NewReader(tc.body)))
|
||||||
|
body := rec.Body.String()
|
||||||
|
if rec.Code != http.StatusBadRequest || !strings.Contains(body, `"`+tc.code+`"`) || !strings.Contains(body, tc.mentions) {
|
||||||
|
t.Errorf("%s: status %d body %s; want 400 %s mentioning %q", name, rec.Code, body, tc.code, tc.mentions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loudnessCoverageResp is the wire shape for GET /api/admin/library/loudness
|
||||||
|
// (M464 #4995). measured + silent + unreadable + pending = total; missing
|
||||||
|
// tracks are not counted. Enabled travels with the counts because with
|
||||||
|
// analysis off, pending never shrinks, and a gauge implying progress would be
|
||||||
|
// promising work nothing is doing.
|
||||||
|
type loudnessCoverageResp struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Measured int64 `json:"measured"`
|
||||||
|
Silent int64 `json:"silent"`
|
||||||
|
Unreadable int64 `json:"unreadable"`
|
||||||
|
Pending int64 `json:"pending"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetLoudnessCoverage implements GET /api/admin/library/loudness: how
|
||||||
|
// far the loudness backfill has got. Always 200; zeros on an empty library.
|
||||||
|
func (h *handlers) handleGetLoudnessCoverage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
row, err := library.LoudnessCoverage(r.Context(), h.pool)
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin: get loudness coverage", apierror.InternalMsg("lookup failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, loudnessCoverageResp{
|
||||||
|
Total: row.Total,
|
||||||
|
Measured: row.Measured,
|
||||||
|
Silent: row.Silent,
|
||||||
|
Unreadable: row.Unreadable,
|
||||||
|
Pending: row.Pending,
|
||||||
|
Enabled: h.loudnessSettings.Get().Enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// loudnessSettingsBody is the wire shape for GET and PUT
|
||||||
|
// /api/admin/library/loudness-settings.
|
||||||
|
type loudnessSettingsBody struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
BackfillConcurrency int32 `json:"backfill_concurrency"`
|
||||||
|
LeveledCacheMB int32 `json:"leveled_cache_mb"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func loudnessSettingsBodyOf(s library.LoudnessSettings) loudnessSettingsBody {
|
||||||
|
return loudnessSettingsBody{
|
||||||
|
Enabled: s.Enabled,
|
||||||
|
BackfillConcurrency: s.BackfillConcurrency,
|
||||||
|
LeveledCacheMB: s.LeveledCacheMB,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetLoudnessSettings implements GET /api/admin/library/loudness-settings.
|
||||||
|
func (h *handlers) handleGetLoudnessSettings(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, loudnessSettingsBodyOf(h.loudnessSettings.Get()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateLoudnessSettings implements PUT /api/admin/library/loudness-settings.
|
||||||
|
// A whole-row write: a body that leaves out the concurrency decodes it as zero,
|
||||||
|
// which is refused rather than saved.
|
||||||
|
func (h *handlers) handleUpdateLoudnessSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req loudnessSettingsBody
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saved, err := h.loudnessSettings.Set(r.Context(), library.LoudnessSettings{
|
||||||
|
Enabled: req.Enabled,
|
||||||
|
BackfillConcurrency: req.BackfillConcurrency,
|
||||||
|
LeveledCacheMB: req.LeveledCacheMB,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, library.ErrLoudnessSettingOutOfRange) {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_setting", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeErrWithLog(w, h.logger, "admin loudness settings: update failed", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, loudnessSettingsBodyOf(saved))
|
||||||
|
}
|
||||||
@@ -23,10 +23,16 @@ type networkSettingsResp struct {
|
|||||||
// arrived and count them rather than guess.
|
// arrived and count them rather than guess.
|
||||||
ForwardedChain string `json:"forwarded_chain"`
|
ForwardedChain string `json:"forwarded_chain"`
|
||||||
RemoteAddr string `json:"remote_addr"`
|
RemoteAddr string `json:"remote_addr"`
|
||||||
|
// PublicURL is where users reach Minstrel; reset emails link to it and
|
||||||
|
// are not sent while it is empty.
|
||||||
|
PublicURL string `json:"public_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Both fields are optional so the proxy card and the public-address card can
|
||||||
|
// each save their own value without overwriting the other's.
|
||||||
type updateNetworkSettingsReq struct {
|
type updateNetworkSettingsReq struct {
|
||||||
TrustedProxyHops int `json:"trusted_proxy_hops"`
|
TrustedProxyHops *int `json:"trusted_proxy_hops"`
|
||||||
|
PublicURL *string `json:"public_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *handlers) handleGetNetworkSettings(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleGetNetworkSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -39,13 +45,37 @@ func (h *handlers) handleUpdateNetworkSettings(w http.ResponseWriter, r *http.Re
|
|||||||
writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON"))
|
writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.netSettings.SetHops(r.Context(), req.TrustedProxyHops); err != nil {
|
if req.TrustedProxyHops == nil && req.PublicURL == nil {
|
||||||
if errors.Is(err, netsettings.ErrHopsOutOfRange) {
|
writeErr(w, apierror.BadRequest("invalid_body", "nothing to update"))
|
||||||
writeErr(w, apierror.BadRequest("invalid_hops", err.Error()))
|
return
|
||||||
|
}
|
||||||
|
// Validate everything before writing anything, so a bad public URL can't
|
||||||
|
// leave the hops half-saved.
|
||||||
|
if req.TrustedProxyHops != nil && (*req.TrustedProxyHops < 0 || *req.TrustedProxyHops > netsettings.MaxTrustedProxyHops) {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_hops", netsettings.ErrHopsOutOfRange.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.PublicURL != nil {
|
||||||
|
if _, err := netsettings.NormalizePublicURL(*req.PublicURL); err != nil {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_public_url", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.TrustedProxyHops != nil {
|
||||||
|
if err := h.netSettings.SetHops(r.Context(), *req.TrustedProxyHops); err != nil {
|
||||||
|
if errors.Is(err, netsettings.ErrHopsOutOfRange) {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_hops", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeErrWithLog(w, h.logger, "admin network: update failed", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.PublicURL != nil {
|
||||||
|
if err := h.netSettings.SetPublicURL(r.Context(), *req.PublicURL); err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin network: public URL update failed", apierror.Internal(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeErrWithLog(w, h.logger, "admin network: update failed", apierror.Internal(err))
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// Echo the payload recomputed under the NEW value, so the card can show
|
// Echo the payload recomputed under the NEW value, so the card can show
|
||||||
// immediately what the change did to this request's own address rather
|
// immediately what the change did to this request's own address rather
|
||||||
@@ -61,5 +91,6 @@ func (h *handlers) networkSettingsPayload(r *http.Request) networkSettingsResp {
|
|||||||
DetectedClientIP: auth.ClientIP(r, hops),
|
DetectedClientIP: auth.ClientIP(r, hops),
|
||||||
ForwardedChain: r.Header.Get("X-Forwarded-For"),
|
ForwardedChain: r.Header.Get("X-Forwarded-For"),
|
||||||
RemoteAddr: r.RemoteAddr,
|
RemoteAddr: r.RemoteAddr,
|
||||||
|
PublicURL: h.netSettings.PublicURL(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,13 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req
|
|||||||
}
|
}
|
||||||
action, err := h.lidarrQuarantine.DeleteFile(r.Context(), id, admin.ID)
|
action, err := h.lidarrQuarantine.DeleteFile(r.Context(), id, admin.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Written in the enveloped shape, not writeAdminJSONErr's bare code: the
|
||||||
|
// message is the part that tells the operator which directory and uid.
|
||||||
|
if apiErr, ok := fileRemoveAPIError(err); ok {
|
||||||
|
logFileRemoveFailure(h.logger, apiErr, "track_id", uuidToString(id))
|
||||||
|
writeErr(w, apiErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
|
case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
|
||||||
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
|
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ func installQuarantineClientFn(t *testing.T, h *handlers) {
|
|||||||
}
|
}
|
||||||
return lidarr.NewClient(c.BaseURL, c.APIKey)
|
return lidarr.NewClient(c.BaseURL, c.APIKey)
|
||||||
}
|
}
|
||||||
h.lidarrQuarantine = lidarrquarantine.NewService(h.pool, cfg, clientFn)
|
h.lidarrQuarantine = lidarrquarantine.NewService(h.pool, cfg, clientFn, h.dataDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// flagDirect bypasses the HTTP handler to seed a quarantine row via the
|
// flagDirect bypasses the HTTP handler to seed a quarantine row via the
|
||||||
|
|||||||
@@ -23,15 +23,17 @@ type removeTrackResponse struct {
|
|||||||
|
|
||||||
// handleRemoveTrack implements DELETE /api/admin/tracks/{id}?unmonitor=true|false.
|
// handleRemoveTrack implements DELETE /api/admin/tracks/{id}?unmonitor=true|false.
|
||||||
//
|
//
|
||||||
// Admin-only (gated by auth.RequireAdmin on the /admin route group). Always
|
// Admin-only (gated by auth.RequireAdmin on the /admin route group). Deletes the
|
||||||
// deletes the file + DB row and runs the album/artist cascade tidy-up. When
|
// file, then the DB row, and runs the album/artist cascade tidy-up — and deletes
|
||||||
|
// nothing at all when the file cannot be removed (#3918). When
|
||||||
// unmonitor=true and the track has an mbid, also calls Lidarr.UnmonitorTrack
|
// unmonitor=true and the track has an mbid, also calls Lidarr.UnmonitorTrack
|
||||||
// — failure there is non-fatal (the destructive part already completed) and
|
// — failure there is non-fatal (the destructive part already completed) and
|
||||||
// surfaces as `lidarr_unmonitor_failed: true` in the success envelope.
|
// surfaces as `lidarr_unmonitor_failed: true` in the success envelope.
|
||||||
//
|
//
|
||||||
// Per spec §5, Lidarr-side errors during the unmonitor step do NOT map to
|
// Per spec §5, Lidarr-side errors during the unmonitor step do NOT map to
|
||||||
// wire error codes; the only error codes this handler emits are not_found,
|
// wire error codes. The codes this handler emits are not_found,
|
||||||
// server_error, plus the auth codes the middleware emits upstream.
|
// library_not_writable (409) and file_delete_failed when the file could not be
|
||||||
|
// removed, server_error, plus the auth codes the middleware emits upstream.
|
||||||
func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
|
||||||
idStr := chi.URLParam(r, "id")
|
idStr := chi.URLParam(r, "id")
|
||||||
trackID, ok := parseUUID(idStr)
|
trackID, ok := parseUUID(idStr)
|
||||||
@@ -66,6 +68,11 @@ func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "track not found"})
|
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "track not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if apiErr, ok := fileRemoveAPIError(err); ok {
|
||||||
|
logFileRemoveFailure(h.logger, apiErr, "track_id", idStr)
|
||||||
|
writeErr(w, apiErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
h.logger.Error("api: remove track failed", "err", err, "track_id", idStr)
|
h.logger.Error("api: remove track failed", "err", err, "track_id", idStr)
|
||||||
writeErr(w, apierror.InternalMsg("remove failed", err))
|
writeErr(w, apierror.InternalMsg("remove failed", err))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ func (h *handlers) handleAdminCreateUser(w http.ResponseWriter, r *http.Request)
|
|||||||
user, err := q.CreateUserAdmin(r.Context(), dbq.CreateUserAdminParams{
|
user, err := q.CreateUserAdmin(r.Context(), dbq.CreateUserAdminParams{
|
||||||
Username: req.Username,
|
Username: req.Username,
|
||||||
PasswordHash: string(hash),
|
PasswordHash: string(hash),
|
||||||
ApiToken: apiToken,
|
ApiTokenHash: auth.HashAPIToken(apiToken),
|
||||||
IsAdmin: req.IsAdmin,
|
IsAdmin: req.IsAdmin,
|
||||||
DisplayName: req.DisplayName,
|
DisplayName: req.DisplayName,
|
||||||
})
|
})
|
||||||
@@ -285,6 +285,19 @@ func (h *handlers) handleAdminResetPassword(w http.ResponseWriter, r *http.Reque
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The target's existing sessions end with the old password. An admin
|
||||||
|
// resetting their own password keeps the session they're using, the
|
||||||
|
// same as a self-service change.
|
||||||
|
sessID, ownSession := auth.SessionIDFromContext(r.Context())
|
||||||
|
if targetID == caller.ID && ownSession {
|
||||||
|
_, err = q.DeleteOtherSessionsForUser(r.Context(), dbq.DeleteOtherSessionsForUserParams{UserID: targetID, ID: sessID})
|
||||||
|
} else {
|
||||||
|
_, err = q.DeleteSessionsForUser(r.Context(), targetID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin reset password: revoke sessions failed", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
audit.WriteOrLog(r.Context(), h.pool, h.logger, caller.ID, targetID, audit.ActionPasswordResetAdmin, nil)
|
audit.WriteOrLog(r.Context(), h.pool, h.logger, caller.ID, targetID, audit.ActionPasswordResetAdmin, nil)
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|||||||
+109
-22
@@ -7,6 +7,8 @@ package api
|
|||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -33,35 +35,54 @@ import (
|
|||||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||||
// RequireUser; everything else is gated by the middleware. The events writer
|
// RequireUser; everything else is gated by the middleware. The events writer
|
||||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService) {
|
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService, acoustIDLookup *library.AcoustIDLookupWorker) {
|
||||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||||
|
setupToken, err := auth.NewSetupToken()
|
||||||
|
if err != nil {
|
||||||
|
// crypto/rand failing means the platform can't make secrets at all;
|
||||||
|
// sessions would be minted from the same source. Nothing to degrade to.
|
||||||
|
panic("api: mint setup token: " + err.Error())
|
||||||
|
}
|
||||||
|
logSetupTokenIfNeeded(pool, logger, setupToken)
|
||||||
h := &handlers{
|
h := &handlers{
|
||||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||||
recSettings: recSettings,
|
recSettings: recSettings,
|
||||||
rng: rng.Float64,
|
rng: rng.Float64,
|
||||||
lidarrCfg: lidarrCfg,
|
lidarrCfg: lidarrCfg,
|
||||||
lidarrRequests: lidarrReqs,
|
lidarrRequests: lidarrReqs,
|
||||||
lidarrQuarantine: lidarrQuar,
|
lidarrQuarantine: lidarrQuar,
|
||||||
tracks: tracksSvc,
|
tracks: tracksSvc,
|
||||||
playlists: playlistsSvc,
|
playlists: playlistsSvc,
|
||||||
coverart: coverEnricher,
|
coverart: coverEnricher,
|
||||||
coverSettings: coverSettings,
|
coverSettings: coverSettings,
|
||||||
tagSettings: tagSettings,
|
tagSettings: tagSettings,
|
||||||
scanner: scanner,
|
scanner: scanner,
|
||||||
scanCfg: scanCfg,
|
scanCfg: scanCfg,
|
||||||
dataDir: dataDir,
|
dataDir: dataDir,
|
||||||
mailer: sender,
|
mailer: sender,
|
||||||
eventbus: bus,
|
eventbus: bus,
|
||||||
playlistScheduler: playlistScheduler,
|
playlistScheduler: playlistScheduler,
|
||||||
streamSecret: streamSecret,
|
streamSecret: streamSecret,
|
||||||
netSettings: netSettings,
|
netSettings: netSettings,
|
||||||
reacqSettings: reacqSettings,
|
reacqSettings: reacqSettings,
|
||||||
librarySize: recommendation.NewLibrarySize(nil),
|
fingerprintSettings: fpSettings,
|
||||||
|
loudnessSettings: loudSettings,
|
||||||
|
acoustIDLookup: acoustIDLookup,
|
||||||
|
leveled: newLeveledRenderer(dataDir, loudSettings, logger),
|
||||||
|
librarySize: recommendation.NewLibrarySize(nil),
|
||||||
|
loginGuard: auth.NewLoginGuard(),
|
||||||
|
setupToken: setupToken,
|
||||||
|
requireSetupToken: true,
|
||||||
|
registerLimit: auth.NewAttemptLimiter(registerPerAddressMax, time.Hour),
|
||||||
|
forgotAddressLimit: auth.NewAttemptLimiter(forgotPerAddressMax, time.Hour),
|
||||||
|
forgotEmailLimit: auth.NewAttemptLimiter(forgotPerEmailMax, time.Hour),
|
||||||
|
resetLimit: auth.NewAttemptLimiter(resetFailuresPerAddressMax, 15*time.Minute),
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Route("/api", func(api chi.Router) {
|
r.Route("/api", func(api chi.Router) {
|
||||||
api.Post("/auth/login", h.handleLogin)
|
api.Post("/auth/login", h.handleLogin)
|
||||||
api.Post("/auth/register", h.handleRegister)
|
api.Post("/auth/register", h.handleRegister)
|
||||||
|
api.Get("/auth/setup-status", h.handleSetupStatus)
|
||||||
api.Post("/auth/forgot-password", h.handleForgotPassword)
|
api.Post("/auth/forgot-password", h.handleForgotPassword)
|
||||||
api.Post("/auth/reset-password", h.handleResetPassword)
|
api.Post("/auth/reset-password", h.handleResetPassword)
|
||||||
|
|
||||||
@@ -78,6 +99,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
// audio format from the path. The {ext} param is consumed by chi
|
// audio format from the path. The {ext} param is consumed by chi
|
||||||
// and ignored by the handler (which keys off {id}). See task #610.
|
// and ignored by the handler (which keys off {id}). See task #610.
|
||||||
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream)
|
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream)
|
||||||
|
// The leveled stream for Sonos/UPnP (M464 #5001): session or a
|
||||||
|
// leveled token, like the plain stream.
|
||||||
|
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/leveled.flac", h.handleGetLeveledStream)
|
||||||
|
|
||||||
api.Group(func(authed chi.Router) {
|
api.Group(func(authed chi.Router) {
|
||||||
authed.Use(auth.RequireUser(pool, netSettings.Hops))
|
authed.Use(auth.RequireUser(pool, netSettings.Hops))
|
||||||
@@ -91,8 +115,12 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
authed.Put("/me/password", h.handleChangePassword)
|
authed.Put("/me/password", h.handleChangePassword)
|
||||||
authed.Put("/me/profile", h.handleUpdateMyProfile)
|
authed.Put("/me/profile", h.handleUpdateMyProfile)
|
||||||
authed.Put("/me/timezone", h.handlePutTimezone)
|
authed.Put("/me/timezone", h.handlePutTimezone)
|
||||||
authed.Get("/me/api-token", h.handleGetMyAPIToken)
|
authed.Get("/me/normalization", h.handleGetMyNormalization)
|
||||||
|
authed.Put("/me/normalization", h.handlePutMyNormalization)
|
||||||
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
|
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
|
||||||
|
authed.Get("/me/subsonic-password", h.handleGetMySubsonicPassword)
|
||||||
|
authed.Post("/me/subsonic-password", h.handleGenerateMySubsonicPassword)
|
||||||
|
authed.Delete("/me/subsonic-password", h.handleClearMySubsonicPassword)
|
||||||
authed.Get("/me/sessions", h.handleListMySessions)
|
authed.Get("/me/sessions", h.handleListMySessions)
|
||||||
authed.Delete("/me/sessions/{id}", h.handleRevokeMySession)
|
authed.Delete("/me/sessions/{id}", h.handleRevokeMySession)
|
||||||
authed.Post("/me/sessions/logout-others", h.handleRevokeMyOtherSessions)
|
authed.Post("/me/sessions/logout-others", h.handleRevokeMyOtherSessions)
|
||||||
@@ -112,6 +140,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
authed.Get("/library/genres", h.handleListGenres)
|
authed.Get("/library/genres", h.handleListGenres)
|
||||||
authed.Get("/library/years", h.handleListAlbumYears)
|
authed.Get("/library/years", h.handleListAlbumYears)
|
||||||
authed.Get("/library/sync", h.handleLibrarySync)
|
authed.Get("/library/sync", h.handleLibrarySync)
|
||||||
|
// Before /tracks/{id} for readability; chi prefers the static
|
||||||
|
// segment either way.
|
||||||
|
authed.Get("/tracks/replay-gain", h.handleGetReplayGain)
|
||||||
authed.Get("/tracks/{id}", h.handleGetTrack)
|
authed.Get("/tracks/{id}", h.handleGetTrack)
|
||||||
// /tracks/{id}/stream is mounted above with OptionalUser so
|
// /tracks/{id}/stream is mounted above with OptionalUser so
|
||||||
// it can accept either a session or a signed token.
|
// it can accept either a session or a signed token.
|
||||||
@@ -215,6 +246,23 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
admin.Get("/library/missing", h.handleListMissingTracks)
|
admin.Get("/library/missing", h.handleListMissingTracks)
|
||||||
|
|
||||||
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
|
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
|
||||||
|
admin.Get("/library/fingerprints", h.handleGetFingerprintCoverage)
|
||||||
|
admin.Get("/library/fingerprint-settings", h.handleGetFingerprintSettings)
|
||||||
|
admin.Put("/library/fingerprint-settings", h.handleUpdateFingerprintSettings)
|
||||||
|
admin.Get("/library/loudness", h.handleGetLoudnessCoverage)
|
||||||
|
admin.Get("/library/loudness-settings", h.handleGetLoudnessSettings)
|
||||||
|
admin.Put("/library/loudness-settings", h.handleUpdateLoudnessSettings)
|
||||||
|
admin.Get("/library/acoustid", h.handleGetAcoustID)
|
||||||
|
admin.Put("/library/acoustid-settings", h.handleUpdateAcoustIDSettings)
|
||||||
|
admin.Post("/library/acoustid/run", h.handleRunAcoustIDLookup)
|
||||||
|
admin.Get("/library/acoustid/unsettled", h.handleListUnsettledAcoustID)
|
||||||
|
// Duplicates report (#3912): proposals from the duplicate sweep, a
|
||||||
|
// trigger to sweep now, dismissal, and the merge (#3911), which deletes
|
||||||
|
// the removed copies' files after moving their history onto the kept one.
|
||||||
|
admin.Get("/library/duplicates", h.handleListDuplicates)
|
||||||
|
admin.Post("/library/duplicates/sweep", h.handleRunDuplicateSweep)
|
||||||
|
admin.Post("/library/duplicates/{id}/dismiss", h.handleDismissDuplicateGroup)
|
||||||
|
admin.Post("/library/duplicates/{id}/merge", h.handleMergeDuplicateGroup)
|
||||||
|
|
||||||
admin.Get("/invites", h.handleListInvites)
|
admin.Get("/invites", h.handleListInvites)
|
||||||
admin.Post("/invites", h.handleCreateInvite)
|
admin.Post("/invites", h.handleCreateInvite)
|
||||||
@@ -298,6 +346,34 @@ type handlers struct {
|
|||||||
// missing files (milestone #290) — grace window, backoff, attempt caps.
|
// missing files (milestone #290) — grace window, backoff, attempt caps.
|
||||||
// Cached in the service, so the admin card reads it without a query.
|
// Cached in the service, so the admin card reads it without a query.
|
||||||
reacqSettings *reacquisition.SettingsService
|
reacqSettings *reacquisition.SettingsService
|
||||||
|
// fingerprintSettings is the fingerprinting policy (M400 #3913), the same
|
||||||
|
// instance the scanner and the fingerprint workers read, so a save from the
|
||||||
|
// admin card reaches them without a restart. Nil serves the defaults.
|
||||||
|
fingerprintSettings *library.FingerprintSettingsService
|
||||||
|
// loudnessSettings is the loudness analysis policy (M464 #4995), the same
|
||||||
|
// instance the loudness backfill reads. Nil serves the defaults.
|
||||||
|
loudnessSettings *library.LoudnessSettingsService
|
||||||
|
// acoustIDLookup is the AcoustID lookup worker (M401): its settings, its
|
||||||
|
// status and "look up now". Nil serves the defaults (off).
|
||||||
|
acoustIDLookup *library.AcoustIDLookupWorker
|
||||||
|
// leveled renders the leveled streams handed to Sonos/UPnP speakers
|
||||||
|
// (M464 #5001). Nil when its cache directory cannot be made: a level
|
||||||
|
// request then gets the plain stream.
|
||||||
|
leveled *library.LeveledRenderer
|
||||||
|
// setupToken must accompany the first registration while no users exist
|
||||||
|
// (see auth.SetupToken). requireSetupToken is set by Mount, the only
|
||||||
|
// production constructor; tests that build handlers directly leave it
|
||||||
|
// off unless they are testing it.
|
||||||
|
setupToken *auth.SetupToken
|
||||||
|
requireSetupToken bool
|
||||||
|
// loginGuard throttles failed logins per account and per address, and
|
||||||
|
// the limiters below cap the other unauthenticated auth routes. All are
|
||||||
|
// nil-safe, so tests that build handlers directly run unthrottled.
|
||||||
|
loginGuard *auth.LoginGuard
|
||||||
|
registerLimit *auth.AttemptLimiter
|
||||||
|
forgotAddressLimit *auth.AttemptLimiter
|
||||||
|
forgotEmailLimit *auth.AttemptLimiter
|
||||||
|
resetLimit *auth.AttemptLimiter
|
||||||
// netSettings caches the trusted reverse-proxy depth read by the auth
|
// netSettings caches the trusted reverse-proxy depth read by the auth
|
||||||
// middleware on every request and edited from the admin network card.
|
// middleware on every request and edited from the admin network card.
|
||||||
netSettings *netsettings.Service
|
netSettings *netsettings.Service
|
||||||
@@ -311,3 +387,14 @@ type handlers struct {
|
|||||||
// anything a client mints), which is the desired slice-1 default.
|
// anything a client mints), which is the desired slice-1 default.
|
||||||
streamSecret []byte
|
streamSecret []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newLeveledRenderer makes the leveled-stream renderer, caching under the
|
||||||
|
// data directory. A failure is logged and leaves leveling off for speakers.
|
||||||
|
func newLeveledRenderer(dataDir string, settings *library.LoudnessSettingsService, logger *slog.Logger) *library.LeveledRenderer {
|
||||||
|
r, err := library.NewLeveledRenderer(filepath.Join(dataDir, "leveled-cache"), settings, logger)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("api: leveled streams unavailable", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|||||||
+30
-2
@@ -19,6 +19,20 @@ import (
|
|||||||
// so an abandoned laptop doesn't stay logged in forever.
|
// so an abandoned laptop doesn't stay logged in forever.
|
||||||
const sessionCookieMaxAge = 30 * 24 * time.Hour
|
const sessionCookieMaxAge = 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
// Limits on the unauthenticated auth routes other than login (which uses
|
||||||
|
// auth.LoginGuard). Per address, per window as wired in Mount.
|
||||||
|
const (
|
||||||
|
// registerPerAddressMax: 10 registrations an hour. Every attempt counts,
|
||||||
|
// typos included, which is still far above a household's need.
|
||||||
|
registerPerAddressMax = 10
|
||||||
|
// forgotPerAddressMax / forgotPerEmailMax: reset emails an hour. The
|
||||||
|
// per-email cap is what keeps one inbox from being mailbombed.
|
||||||
|
forgotPerAddressMax = 5
|
||||||
|
forgotPerEmailMax = 3
|
||||||
|
// resetFailuresPerAddressMax: wrong or expired reset tokens per 15 min.
|
||||||
|
resetFailuresPerAddressMax = 20
|
||||||
|
)
|
||||||
|
|
||||||
func (h *handlers) handleLogout(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
// The session token can be on the cookie OR bearer header — RequireUser
|
// The session token can be on the cookie OR bearer header — RequireUser
|
||||||
// accepted either. Re-resolve it here so we can delete the row.
|
// accepted either. Re-resolve it here so we can delete the row.
|
||||||
@@ -70,10 +84,22 @@ func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Checked before any lookup or bcrypt, so a throttled guess costs the
|
||||||
|
// server nothing and learns nothing.
|
||||||
|
addr := auth.ClientIP(r, h.netSettings.Hops())
|
||||||
|
if blocked, wait := h.loginGuard.Blocked(req.Username, addr); blocked {
|
||||||
|
writeRateLimited(w, wait)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
q := dbq.New(h.pool)
|
q := dbq.New(h.pool)
|
||||||
user, err := q.GetUserByUsername(r.Context(), req.Username)
|
user, err := q.GetUserByUsername(r.Context(), req.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
// Same bcrypt time as a wrong password, so timing doesn't say
|
||||||
|
// which usernames exist.
|
||||||
|
auth.DummyVerify(req.Password)
|
||||||
|
h.loginGuard.Fail(req.Username, addr)
|
||||||
writeErr(w, apierror.Unauthorized("invalid_credentials", "invalid username or password"))
|
writeErr(w, apierror.Unauthorized("invalid_credentials", "invalid username or password"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -82,9 +108,11 @@ func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !auth.VerifyPassword(user.PasswordHash, req.Password) {
|
if !auth.VerifyPassword(user.PasswordHash, req.Password) {
|
||||||
|
h.loginGuard.Fail(req.Username, addr)
|
||||||
writeErr(w, apierror.Unauthorized("invalid_credentials", "invalid username or password"))
|
writeErr(w, apierror.Unauthorized("invalid_credentials", "invalid username or password"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.loginGuard.Succeed(req.Username)
|
||||||
|
|
||||||
token, err := auth.MintSessionToken()
|
token, err := auth.MintSessionToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -100,7 +128,7 @@ func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
// the active-sessions surface: a session that was born somewhere the
|
// the active-sessions surface: a session that was born somewhere the
|
||||||
// user recognises but is being used from somewhere they don't is the
|
// user recognises but is being used from somewhere they don't is the
|
||||||
// case this whole surface exists to surface.
|
// case this whole surface exists to surface.
|
||||||
Ip: auth.ClientIP(r, h.netSettings.Hops()),
|
Ip: addr,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
h.logger.Error("api: insert session failed", "err", err)
|
h.logger.Error("api: insert session failed", "err", err)
|
||||||
writeErr(w, apierror.InternalMsg("insert failed", err))
|
writeErr(w, apierror.InternalMsg("insert failed", err))
|
||||||
@@ -112,7 +140,7 @@ func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
Value: token,
|
Value: token,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: r.TLS != nil, // dev over http stays functional; prod over https gets Secure
|
Secure: auth.IsHTTPS(r, h.netSettings.Hops()), // plain HTTP and LAN stay functional (rule 94)
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteStrictMode,
|
||||||
MaxAge: int(sessionCookieMaxAge.Seconds()),
|
MaxAge: int(sessionCookieMaxAge.Seconds()),
|
||||||
})
|
})
|
||||||
|
|||||||
+33
-12
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||||
)
|
)
|
||||||
@@ -47,6 +48,19 @@ func (h *handlers) handleForgotPassword(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
email := strings.ToLower(strings.TrimSpace(req.Email))
|
email := strings.ToLower(strings.TrimSpace(req.Email))
|
||||||
|
|
||||||
|
// Throttled per address (a spray) and per email (a mailbomb of one
|
||||||
|
// inbox). Applied whether or not the email matches, so a 429 says
|
||||||
|
// nothing about which addresses are registered.
|
||||||
|
addr := auth.ClientIP(r, h.netSettings.Hops())
|
||||||
|
blockedAddr, waitAddr := h.forgotAddressLimit.Blocked(addr)
|
||||||
|
blockedEmail, waitEmail := h.forgotEmailLimit.Blocked(email)
|
||||||
|
if blockedAddr || blockedEmail {
|
||||||
|
writeRateLimited(w, max(waitAddr, waitEmail))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.forgotAddressLimit.Record(addr)
|
||||||
|
h.forgotEmailLimit.Record(email)
|
||||||
|
|
||||||
q := dbq.New(h.pool)
|
q := dbq.New(h.pool)
|
||||||
var matched bool
|
var matched bool
|
||||||
var auditTarget pgtype.UUID
|
var auditTarget pgtype.UUID
|
||||||
@@ -55,7 +69,7 @@ func (h *handlers) handleForgotPassword(w http.ResponseWriter, r *http.Request)
|
|||||||
if err == nil && user.Email != nil && *user.Email != "" {
|
if err == nil && user.Email != nil && *user.Email != "" {
|
||||||
matched = true
|
matched = true
|
||||||
auditTarget = user.ID
|
auditTarget = user.ID
|
||||||
if sendErr := h.sendResetEmail(r.Context(), r, user); sendErr != nil {
|
if sendErr := h.sendResetEmail(r.Context(), user); sendErr != nil {
|
||||||
h.logger.Warn("forgot-password: send failed",
|
h.logger.Warn("forgot-password: send failed",
|
||||||
"email", email, "err", sendErr)
|
"email", email, "err", sendErr)
|
||||||
// fall through; response is still 200
|
// fall through; response is still 200
|
||||||
@@ -75,7 +89,7 @@ func (h *handlers) handleForgotPassword(w http.ResponseWriter, r *http.Request)
|
|||||||
// sendResetEmail generates a token, persists it, renders + sends the
|
// sendResetEmail generates a token, persists it, renders + sends the
|
||||||
// email. Returns the underlying error (caller logs it but doesn't
|
// email. Returns the underlying error (caller logs it but doesn't
|
||||||
// surface to the HTTP response).
|
// surface to the HTTP response).
|
||||||
func (h *handlers) sendResetEmail(ctx context.Context, r *http.Request, user dbq.User) error {
|
func (h *handlers) sendResetEmail(ctx context.Context, user dbq.User) error {
|
||||||
if h.mailer == nil {
|
if h.mailer == nil {
|
||||||
return errors.New("forgot-password: no mailer configured")
|
return errors.New("forgot-password: no mailer configured")
|
||||||
}
|
}
|
||||||
@@ -95,7 +109,10 @@ func (h *handlers) sendResetEmail(ctx context.Context, r *http.Request, user dbq
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
resetURL := buildResetURL(r, token)
|
resetURL, err := buildResetURL(h.netSettings.PublicURL(), token)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
textBody, htmlBody, err := mailer.RenderResetEmail(mailer.ResetEmailVars{
|
textBody, htmlBody, err := mailer.RenderResetEmail(mailer.ResetEmailVars{
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
ResetURL: resetURL,
|
ResetURL: resetURL,
|
||||||
@@ -113,14 +130,18 @@ func (h *handlers) sendResetEmail(ctx context.Context, r *http.Request, user dbq
|
|||||||
return h.mailer.Send(ctx, *user.Email, mailer.ResetEmailSubject, textBody, htmlBody)
|
return h.mailer.Send(ctx, *user.Email, mailer.ResetEmailSubject, textBody, htmlBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildResetURL(r *http.Request, token string) string {
|
// errNoPublicURL stops a reset email from going out before the operator has
|
||||||
scheme := "http"
|
// said where Minstrel lives. It surfaces in the log, not the response, which
|
||||||
if r.TLS != nil {
|
// stays the same opaque 200 either way.
|
||||||
scheme = "https"
|
var errNoPublicURL = errors.New("forgot-password: no public URL set (Admin → Integrations → Public address); reset email not sent")
|
||||||
|
|
||||||
|
// buildResetURL builds the emailed link from the operator-set public URL.
|
||||||
|
// It deliberately ignores the request: the Host header is whatever the
|
||||||
|
// requester sent, and building from it let a forged Host plant a real reset
|
||||||
|
// token on a link to someone else's server.
|
||||||
|
func buildResetURL(publicURL, token string) (string, error) {
|
||||||
|
if publicURL == "" {
|
||||||
|
return "", errNoPublicURL
|
||||||
}
|
}
|
||||||
host := r.Host
|
return publicURL + "/reset-password/" + token, nil
|
||||||
if host == "" {
|
|
||||||
host = "localhost"
|
|
||||||
}
|
|
||||||
return scheme + "://" + host + "/reset-password/" + token
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,59 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// withPublicURL gives h a network-settings service holding url, restoring an
|
||||||
|
// empty value afterwards so other tests see the default.
|
||||||
|
func withPublicURL(t *testing.T, h *handlers, pool *pgxpool.Pool, url string) {
|
||||||
|
t.Helper()
|
||||||
|
ns, err := netsettings.New(context.Background(), pool, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("netsettings: %v", err)
|
||||||
|
}
|
||||||
|
if err := ns.SetPublicURL(context.Background(), url); err != nil {
|
||||||
|
t.Fatalf("set public url: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = ns.SetPublicURL(context.Background(), "") })
|
||||||
|
h.netSettings = ns
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no public URL set, a reset email is not sent at all, and the response
|
||||||
|
// is still the same opaque 200.
|
||||||
|
func TestForgotPassword_NoPublicURL_SendsNothing(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
fake := &mailer.FakeSender{}
|
||||||
|
h.mailer = fake
|
||||||
|
withPublicURL(t, h, pool, "")
|
||||||
|
|
||||||
|
user := seedUser(t, pool, "nourl", "pw", false)
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
"UPDATE users SET email = 'nourl@example.com' WHERE id = $1", user.ID); err != nil {
|
||||||
|
t.Fatalf("seed email: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password",
|
||||||
|
bytes.NewReader([]byte(`{"email":"nourl@example.com"}`)))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.handleForgotPassword(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if len(fake.Sent) != 0 {
|
||||||
|
t.Errorf("sent %d emails with no public URL set, want 0", len(fake.Sent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestForgotPassword_KnownEmail_SendsMail(t *testing.T) {
|
func TestForgotPassword_KnownEmail_SendsMail(t *testing.T) {
|
||||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
@@ -19,6 +67,7 @@ func TestForgotPassword_KnownEmail_SendsMail(t *testing.T) {
|
|||||||
h, pool := testHandlers(t)
|
h, pool := testHandlers(t)
|
||||||
fake := &mailer.FakeSender{}
|
fake := &mailer.FakeSender{}
|
||||||
h.mailer = fake
|
h.mailer = fake
|
||||||
|
withPublicURL(t, h, pool, "https://music.example.com")
|
||||||
|
|
||||||
user := seedUser(t, pool, "forgotuser", "pw", false)
|
user := seedUser(t, pool, "forgotuser", "pw", false)
|
||||||
if _, err := pool.Exec(context.Background(),
|
if _, err := pool.Exec(context.Background(),
|
||||||
@@ -29,7 +78,8 @@ func TestForgotPassword_KnownEmail_SendsMail(t *testing.T) {
|
|||||||
body := `{"email":"forgot@example.com"}`
|
body := `{"email":"forgot@example.com"}`
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password",
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password",
|
||||||
bytes.NewReader([]byte(body)))
|
bytes.NewReader([]byte(body)))
|
||||||
req.Host = "minstrel.example.com"
|
// A forged Host must not reach the link: it comes from the public URL.
|
||||||
|
req.Host = "attacker.example.net"
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.handleForgotPassword(rec, req)
|
h.handleForgotPassword(rec, req)
|
||||||
|
|
||||||
@@ -43,6 +93,12 @@ func TestForgotPassword_KnownEmail_SendsMail(t *testing.T) {
|
|||||||
if got.To != "forgot@example.com" {
|
if got.To != "forgot@example.com" {
|
||||||
t.Errorf("To = %q, want forgot@example.com", got.To)
|
t.Errorf("To = %q, want forgot@example.com", got.To)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(got.TextBody, "https://music.example.com/reset-password/") {
|
||||||
|
t.Errorf("reset link not built from the public URL:\n%s", got.TextBody)
|
||||||
|
}
|
||||||
|
if strings.Contains(got.TextBody+got.HTMLBody, "attacker.example.net") {
|
||||||
|
t.Error("the request's Host header reached the emailed link")
|
||||||
|
}
|
||||||
// Token row was inserted.
|
// Token row was inserted.
|
||||||
var tokenCount int
|
var tokenCount int
|
||||||
if err := pool.QueryRow(context.Background(),
|
if err := pool.QueryRow(context.Background(),
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"time"
|
"time"
|
||||||
@@ -10,6 +12,7 @@ import (
|
|||||||
"github.com/jackc/pgerrcode"
|
"github.com/jackc/pgerrcode"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
@@ -26,9 +29,12 @@ var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{3,32}$`)
|
|||||||
const minPasswordLength = 8
|
const minPasswordLength = 8
|
||||||
|
|
||||||
type registerReq struct {
|
type registerReq struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
InviteToken string `json:"invite_token"`
|
InviteToken string `json:"invite_token"`
|
||||||
|
// SetupToken is required only for the very first account; see
|
||||||
|
// auth.SetupToken.
|
||||||
|
SetupToken string `json:"setup_token"`
|
||||||
DisplayName *string `json:"display_name"`
|
DisplayName *string `json:"display_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +61,15 @@ type registerReq struct {
|
|||||||
// - 409 username_taken (PG unique violation on users.username)
|
// - 409 username_taken (PG unique violation on users.username)
|
||||||
// - 500 server_error otherwise
|
// - 500 server_error otherwise
|
||||||
func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Every attempt counts, not only failures: a successful registration is
|
||||||
|
// the thing being sprayed when the mode is open.
|
||||||
|
addr := auth.ClientIP(r, h.netSettings.Hops())
|
||||||
|
if blocked, wait := h.registerLimit.Blocked(addr); blocked {
|
||||||
|
writeRateLimited(w, wait)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.registerLimit.Record(addr)
|
||||||
|
|
||||||
var req registerReq
|
var req registerReq
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeErr(w, apierror.BadRequest("invalid_body", "invalid JSON body"))
|
writeErr(w, apierror.BadRequest("invalid_body", "invalid JSON body"))
|
||||||
@@ -79,6 +94,19 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The first account becomes admin, so it must prove it can read the
|
||||||
|
// server log. Checked before the invite logic, which the empty-users
|
||||||
|
// state skips.
|
||||||
|
if userCount == 0 && h.requireSetupToken && !h.setupToken.Matches(req.SetupToken) {
|
||||||
|
// Repeat the token in the log at the moment someone needs it: the
|
||||||
|
// boot line may have scrolled away, or users may have been deleted
|
||||||
|
// since boot.
|
||||||
|
h.logger.Warn("register: first-admin registration needs the setup token",
|
||||||
|
"setup_token", h.setupToken.Value())
|
||||||
|
writeErr(w, apierror.Forbidden("setup_token_invalid", "the setup token from the server log is required to create the first account"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Validate invite (skipped on empty-users state; skipped in 'open' mode).
|
// Validate invite (skipped on empty-users state; skipped in 'open' mode).
|
||||||
usedInviteToken := ""
|
usedInviteToken := ""
|
||||||
if userCount > 0 {
|
if userCount > 0 {
|
||||||
@@ -133,7 +161,7 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
user, err := q.CreateUserFirstAdminRace(r.Context(), dbq.CreateUserFirstAdminRaceParams{
|
user, err := q.CreateUserFirstAdminRace(r.Context(), dbq.CreateUserFirstAdminRaceParams{
|
||||||
Username: req.Username,
|
Username: req.Username,
|
||||||
PasswordHash: string(hash),
|
PasswordHash: string(hash),
|
||||||
ApiToken: apiToken,
|
ApiTokenHash: auth.HashAPIToken(apiToken),
|
||||||
DisplayName: req.DisplayName,
|
DisplayName: req.DisplayName,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -175,7 +203,7 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
TokenHash: auth.HashSessionToken(sessionToken),
|
TokenHash: auth.HashSessionToken(sessionToken),
|
||||||
UserAgent: r.UserAgent(),
|
UserAgent: r.UserAgent(),
|
||||||
Ip: auth.ClientIP(r, h.netSettings.Hops()),
|
Ip: addr,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
h.logger.Error("register: insert session failed", "err", err)
|
h.logger.Error("register: insert session failed", "err", err)
|
||||||
writeErr(w, apierror.Internal(err))
|
writeErr(w, apierror.Internal(err))
|
||||||
@@ -186,7 +214,7 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
Value: sessionToken,
|
Value: sessionToken,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: r.TLS != nil,
|
Secure: auth.IsHTTPS(r, h.netSettings.Hops()), // plain HTTP and LAN stay functional (rule 94)
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteStrictMode,
|
||||||
MaxAge: int(sessionCookieMaxAge.Seconds()),
|
MaxAge: int(sessionCookieMaxAge.Seconds()),
|
||||||
})
|
})
|
||||||
@@ -223,3 +251,31 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleSetupStatus implements GET /api/auth/setup-status. It tells the
|
||||||
|
// register screen whether to ask for the setup token, i.e. whether no
|
||||||
|
// account exists yet. Public, since it is needed before anyone can sign in;
|
||||||
|
// "this server has no users" is not worth hiding from someone who could
|
||||||
|
// simply try to register.
|
||||||
|
func (h *handlers) handleSetupStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n, err := dbq.New(h.pool).CountUsers(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "setup status: count users failed", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"setup_required": n == 0})
|
||||||
|
}
|
||||||
|
|
||||||
|
// logSetupTokenIfNeeded writes the setup token to the log at boot when the
|
||||||
|
// instance has no accounts yet, with the instruction for using it.
|
||||||
|
func logSetupTokenIfNeeded(pool *pgxpool.Pool, logger *slog.Logger, token *auth.SetupToken) {
|
||||||
|
if pool == nil || logger == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := dbq.New(pool).CountUsers(context.Background())
|
||||||
|
if err != nil || n > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Warn("no accounts yet: open the web app, choose Create account, and enter this setup token to become the admin",
|
||||||
|
"setup_token", token.Value())
|
||||||
|
}
|
||||||
|
|||||||
@@ -273,3 +273,75 @@ func TestRegister_FirstAdminRace(t *testing.T) {
|
|||||||
// not "exactly one" (which would require serializable isolation).
|
// not "exactly one" (which would require serializable isolation).
|
||||||
t.Logf("admin count after race = %d (>=1 is the invariant)", adminCount)
|
t.Logf("admin count after race = %d (>=1 is the invariant)", adminCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// With the gate on (as Mount sets it), the first account needs the setup
|
||||||
|
// token from the log; a missing or wrong one is refused and creates nothing.
|
||||||
|
func TestRegister_FirstUserNeedsSetupToken(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
resetUsers(t, h)
|
||||||
|
token, err := auth.NewSetupToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mint: %v", err)
|
||||||
|
}
|
||||||
|
h.setupToken = token
|
||||||
|
h.requireSetupToken = true
|
||||||
|
|
||||||
|
register := func(body string) int {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader([]byte(body)))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.handleRegister(rec, req)
|
||||||
|
return rec.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
if code := register(`{"username":"squatter","password":"abcd1234"}`); code != http.StatusForbidden {
|
||||||
|
t.Errorf("no token: status = %d, want 403", code)
|
||||||
|
}
|
||||||
|
if code := register(`{"username":"squatter","password":"abcd1234","setup_token":"wrong"}`); code != http.StatusForbidden {
|
||||||
|
t.Errorf("wrong token: status = %d, want 403", code)
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if err := h.pool.QueryRow(context.Background(), `SELECT count(*) FROM users`).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("a refused registration created %d account(s)", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if code := register(`{"username":"operator","password":"abcd1234","setup_token":"` + token.Value() + `"}`); code != http.StatusOK {
|
||||||
|
t.Fatalf("right token: status = %d, want 200", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once an account exists the token plays no part: the second user is
|
||||||
|
// governed by registration mode, not the setup token.
|
||||||
|
if _, err := h.pool.Exec(context.Background(),
|
||||||
|
`UPDATE registration_settings SET mode = 'open' WHERE id = true`); err != nil {
|
||||||
|
t.Fatalf("open mode: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = h.pool.Exec(context.Background(),
|
||||||
|
`UPDATE registration_settings SET mode = 'invite_only' WHERE id = true`)
|
||||||
|
})
|
||||||
|
if code := register(`{"username":"second","password":"abcd1234"}`); code != http.StatusOK {
|
||||||
|
t.Errorf("second user without token: status = %d, want 200", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetupToken_MatchesOnlyItself(t *testing.T) {
|
||||||
|
tok, err := auth.NewSetupToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mint: %v", err)
|
||||||
|
}
|
||||||
|
if !tok.Matches(tok.Value()) {
|
||||||
|
t.Error("token does not match itself")
|
||||||
|
}
|
||||||
|
if tok.Matches("") || tok.Matches(tok.Value()+"x") {
|
||||||
|
t.Error("token matched something else")
|
||||||
|
}
|
||||||
|
var none *auth.SetupToken
|
||||||
|
if none.Matches("") || none.Matches("anything") {
|
||||||
|
t.Error("a nil token must fail closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,6 +43,15 @@ func (h *handlers) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tokens carry 256 bits, so guessing one is hopeless; the limit is on
|
||||||
|
// failed tries per address so that nobody gets to find that out at our
|
||||||
|
// expense.
|
||||||
|
addr := auth.ClientIP(r, h.netSettings.Hops())
|
||||||
|
if blocked, wait := h.resetLimit.Blocked(addr); blocked {
|
||||||
|
writeRateLimited(w, wait)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
q := dbq.New(h.pool)
|
q := dbq.New(h.pool)
|
||||||
|
|
||||||
// Look up the reset record so we know which user to update before
|
// Look up the reset record so we know which user to update before
|
||||||
@@ -50,6 +60,7 @@ func (h *handlers) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
reset, err := q.GetPasswordReset(r.Context(), req.Token)
|
reset, err := q.GetPasswordReset(r.Context(), req.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
h.resetLimit.Record(addr)
|
||||||
writeErr(w, apierror.BadRequest("invalid_token", ""))
|
writeErr(w, apierror.BadRequest("invalid_token", ""))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -69,6 +80,7 @@ func (h *handlers) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
|
h.resetLimit.Record(addr)
|
||||||
writeErr(w, apierror.BadRequest("invalid_token", ""))
|
writeErr(w, apierror.BadRequest("invalid_token", ""))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -92,6 +104,13 @@ func (h *handlers) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// End every session the account has. Whoever needed a reset isn't signed
|
||||||
|
// in anywhere they rely on, and whoever may have learned the old password
|
||||||
|
// must not stay signed in on it.
|
||||||
|
if _, err := q.DeleteSessionsForUser(r.Context(), reset.UserID); err != nil {
|
||||||
|
h.logger.Error("reset password: revoke sessions failed", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
audit.WriteOrLog(r.Context(), h.pool, h.logger, reset.UserID, reset.UserID, audit.ActionPasswordResetByEmail, nil)
|
audit.WriteOrLog(r.Context(), h.pool, h.logger, reset.UserID, reset.UserID, audit.ActionPasswordResetByEmail, nil)
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
|||||||
}
|
}
|
||||||
lidarrCfg := lidarrconfig.New(pool)
|
lidarrCfg := lidarrconfig.New(pool)
|
||||||
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
|
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
|
||||||
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil)
|
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil, "")
|
||||||
// tracks.Service has no Lidarr unmonitorer in tests by default; the
|
// tracks.Service has no Lidarr unmonitorer in tests by default; the
|
||||||
// admin-tracks tests below override h.tracks via installTracksLidarrStub
|
// admin-tracks tests below override h.tracks via installTracksLidarrStub
|
||||||
// when they need a stubbed Lidarr.
|
// when they need a stubbed Lidarr.
|
||||||
@@ -91,7 +91,7 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, username, password string, isAdm
|
|||||||
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
|
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
|
||||||
Username: prefixed,
|
Username: prefixed,
|
||||||
PasswordHash: string(hash),
|
PasswordHash: string(hash),
|
||||||
ApiToken: "test-api-token-" + prefixed,
|
ApiTokenHash: "test-api-token-" + prefixed,
|
||||||
IsAdmin: isAdmin,
|
IsAdmin: isAdmin,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -179,6 +179,36 @@ func TestHandleLogin_UnknownUserReturns401(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A guesser who reaches the account limit is refused with 429 before any
|
||||||
|
// password check, so even the right password is turned away until the window
|
||||||
|
// passes, and the response says when to come back.
|
||||||
|
func TestHandleLogin_ThrottledAfterRepeatedFailures(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
h.loginGuard = auth.NewLoginGuard()
|
||||||
|
seedUser(t, pool, "alice", "hunter2", false)
|
||||||
|
|
||||||
|
login := func(password string) *httptest.ResponseRecorder {
|
||||||
|
body := strings.NewReader(`{"username":"test-alice","password":"` + password + `"}`)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.handleLogin(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
if w := login("wrong"); w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("attempt %d: status = %d, want 401", i+1, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w := login("hunter2")
|
||||||
|
if w.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("status = %d, want 429 once the account limit is reached", w.Code)
|
||||||
|
}
|
||||||
|
if w.Header().Get("Retry-After") == "" {
|
||||||
|
t.Error("429 without Retry-After")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleLogin_MalformedBodyReturns400(t *testing.T) {
|
func TestHandleLogin_MalformedBodyReturns400(t *testing.T) {
|
||||||
h, _ := testHandlers(t)
|
h, _ := testHandlers(t)
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login",
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/login",
|
||||||
|
|||||||
+46
-10
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -19,6 +20,17 @@ const (
|
|||||||
type castTokenRequest struct {
|
type castTokenRequest struct {
|
||||||
TrackID string `json:"trackId"`
|
TrackID string `json:"trackId"`
|
||||||
ExpSeconds int `json:"expSeconds,omitempty"`
|
ExpSeconds int `json:"expSeconds,omitempty"`
|
||||||
|
// Level asks for the leveled stream (M464 #5001): the track rendered at
|
||||||
|
// the user's loudness gain. AsAlbum says the track is playing as part of
|
||||||
|
// its album in order, which picks album gain in auto mode; only the
|
||||||
|
// client holding the queue knows it.
|
||||||
|
Level bool `json:"level,omitempty"`
|
||||||
|
AsAlbum bool `json:"asAlbum,omitempty"`
|
||||||
|
// Prerender says the speaker will fetch this track soon: the current
|
||||||
|
// track at a queue load, or the next one as it starts. Only those are
|
||||||
|
// rendered ahead; a queue load mints every track and renders none of
|
||||||
|
// the rest until the speaker asks.
|
||||||
|
Prerender bool `json:"prerender,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type castTokenResponse struct {
|
type castTokenResponse struct {
|
||||||
@@ -31,6 +43,10 @@ type castTokenResponse struct {
|
|||||||
// `<res protocolInfo>` and `<dc:title>` without a follow-up round trip.
|
// `<res protocolInfo>` and `<dc:title>` without a follow-up round trip.
|
||||||
MIME string `json:"mime"`
|
MIME string `json:"mime"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
// Leveled is true when URL is the leveled stream. A level request still
|
||||||
|
// gets the plain stream when there is nothing to change: leveling off,
|
||||||
|
// the track not yet measured, or a gain of 0.
|
||||||
|
Leveled bool `json:"leveled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// mimeForFormat returns the audio MIME type for a cast (Sonos/UPnP) URL.
|
// mimeForFormat returns the audio MIME type for a cast (Sonos/UPnP) URL.
|
||||||
@@ -89,7 +105,8 @@ func extForFormat(format string) string {
|
|||||||
// Part of the output-picker UPnP slice. See
|
// Part of the output-picker UPnP slice. See
|
||||||
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
||||||
func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request) {
|
||||||
if _, ok := requireUser(w, r); !ok {
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req castTokenRequest
|
var req castTokenRequest
|
||||||
@@ -112,6 +129,27 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request)
|
|||||||
expSec := clampExpSeconds(req.ExpSeconds)
|
expSec := clampExpSeconds(req.ExpSeconds)
|
||||||
exp := time.Now().Add(time.Duration(expSec) * time.Second).Unix()
|
exp := time.Now().Add(time.Duration(expSec) * time.Second).Unix()
|
||||||
token := SignStreamToken(h.streamSecret, req.TrackID, exp)
|
token := SignStreamToken(h.streamSecret, req.TrackID, exp)
|
||||||
|
path := streamURLWithExt(trackUUID, extForFormat(track.FileFormat)) +
|
||||||
|
"?token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
|
||||||
|
mime := mimeForFormat(track.FileFormat)
|
||||||
|
leveled := false
|
||||||
|
if req.Level && h.leveled != nil {
|
||||||
|
g, err := h.leveledGainFor(r.Context(), user.ID, trackUUID, req.AsAlbum)
|
||||||
|
if err != nil {
|
||||||
|
// The plain stream still plays; only the leveling is lost.
|
||||||
|
h.logger.Warn("cast token: leveled gain lookup failed", "track", req.TrackID, "err", err)
|
||||||
|
} else if !g.Unity() {
|
||||||
|
token = SignLeveledStreamToken(h.streamSecret, req.TrackID, exp, g)
|
||||||
|
path = leveledStreamPath(trackUUID) + leveledQuery(g, token, exp)
|
||||||
|
mime = "audio/flac"
|
||||||
|
leveled = true
|
||||||
|
if req.Prerender {
|
||||||
|
h.leveled.Prerender(library.LeveledSource{
|
||||||
|
TrackID: req.TrackID, Path: track.FilePath, DurationMs: track.DurationMs,
|
||||||
|
}, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Behind a TLS-terminating reverse proxy, r.TLS is nil even though
|
// Behind a TLS-terminating reverse proxy, r.TLS is nil even though
|
||||||
// the public-facing URL is https://. UPnP devices (Sonos especially)
|
// the public-facing URL is https://. UPnP devices (Sonos especially)
|
||||||
@@ -131,18 +169,16 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request)
|
|||||||
if h := r.Header.Get("X-Forwarded-Host"); h != "" {
|
if h := r.Header.Get("X-Forwarded-Host"); h != "" {
|
||||||
host = h
|
host = h
|
||||||
}
|
}
|
||||||
// Include the file extension in the path so Sonos's URL probe sees a
|
// The path carries a file extension so Sonos's URL probe sees a
|
||||||
// recognizable audio file. Without it, Sonos reports TrackDuration=0
|
// recognizable audio file. Without it, Sonos reports TrackDuration=0
|
||||||
// and seeks past 0s land "after the end" -> early track-skip.
|
// and seeks past 0s land "after the end" -> early track-skip.
|
||||||
url := scheme + "://" + host + streamURLWithExt(trackUUID, extForFormat(track.FileFormat)) +
|
|
||||||
"?token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, castTokenResponse{
|
writeJSON(w, http.StatusOK, castTokenResponse{
|
||||||
Token: token,
|
Token: token,
|
||||||
Exp: exp,
|
Exp: exp,
|
||||||
URL: url,
|
URL: scheme + "://" + host + path,
|
||||||
MIME: mimeForFormat(track.FileFormat),
|
MIME: mime,
|
||||||
Title: track.Title,
|
Title: track.Title,
|
||||||
|
Leveled: leveled,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
)
|
)
|
||||||
@@ -36,6 +39,17 @@ func writeErr(w http.ResponseWriter, err error) {
|
|||||||
}})
|
}})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeRateLimited answers 429 with Retry-After in whole seconds, rounded
|
||||||
|
// up so a client that honours it never arrives a moment early.
|
||||||
|
func writeRateLimited(w http.ResponseWriter, wait time.Duration) {
|
||||||
|
secs := int(math.Ceil(wait.Seconds()))
|
||||||
|
if secs < 1 {
|
||||||
|
secs = 1
|
||||||
|
}
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
||||||
|
writeErr(w, apierror.TooManyRequests("rate_limited", "too many attempts; try again later"))
|
||||||
|
}
|
||||||
|
|
||||||
// writeErrWithLog logs the error at Error level and writes the response.
|
// writeErrWithLog logs the error at Error level and writes the response.
|
||||||
// Use for 500-class errors where the operator needs the cause in logs.
|
// Use for 500-class errors where the operator needs the cause in logs.
|
||||||
func writeErrWithLog(w http.ResponseWriter, logger *slog.Logger, msg string, err error) {
|
func writeErrWithLog(w http.ResponseWriter, logger *slog.Logger, msg string, err error) {
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fileRemoveAPIError answers a delete that could not reach the track's file
|
||||||
|
// (#3918). Both delete endpoints use it, so the operator gets the same
|
||||||
|
// explanation from the admin remove-track action and from quarantine's Delete
|
||||||
|
// file.
|
||||||
|
//
|
||||||
|
// The unwritable case is a 409 rather than a 500 because nothing is broken: the
|
||||||
|
// request conflicts with how the library is mounted, and the fix is the
|
||||||
|
// operator's. The message names the directory — removal writes to the parent,
|
||||||
|
// not the file — and the uid/gid the process runs as, which is the half of a
|
||||||
|
// permission problem invisible from the host. Every case says nothing was
|
||||||
|
// deleted, because that is exactly what the operator will be worried about.
|
||||||
|
func fileRemoveAPIError(err error) (*apierror.Error, bool) {
|
||||||
|
var fre *library.FileRemoveError
|
||||||
|
if !errors.As(err, &fre) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if fre.NotWritable() {
|
||||||
|
return &apierror.Error{
|
||||||
|
Status: http.StatusConflict,
|
||||||
|
Code: "library_not_writable",
|
||||||
|
Message: fmt.Sprintf(
|
||||||
|
"Minstrel runs as uid %d, gid %d and cannot delete from %s (%s). "+
|
||||||
|
"The library mount must be writable by that user. Nothing was deleted.",
|
||||||
|
fre.UID, fre.GID, fre.Dir(), fre.Reason()),
|
||||||
|
Cause: err,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
return &apierror.Error{
|
||||||
|
Status: http.StatusInternalServerError,
|
||||||
|
Code: "file_delete_failed",
|
||||||
|
Message: fmt.Sprintf("Could not delete %s (%s). Nothing was deleted.", fre.Path, fre.Reason()),
|
||||||
|
Cause: err,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// logFileRemoveFailure records a delete that could not reach its file. An
|
||||||
|
// unwritable library is an environment fact the operator can fix, so it is a
|
||||||
|
// Warn; anything else is a real fault.
|
||||||
|
func logFileRemoveFailure(logger *slog.Logger, apiErr *apierror.Error, attrs ...any) {
|
||||||
|
attrs = append(attrs, "code", apiErr.Code, "err", apiErr.Cause)
|
||||||
|
if apiErr.Status == http.StatusConflict {
|
||||||
|
logger.Warn("api: track file could not be deleted", attrs...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Error("api: track file could not be deleted", attrs...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
|
)
|
||||||
|
|
||||||
|
const removeTestPath = "/music/Moe Shop/WWW (2020)/01 - WWW.mp3"
|
||||||
|
|
||||||
|
// removeFailure builds the error a delete service returns when the file would
|
||||||
|
// not go, wrapped the way lidarrquarantine.DeleteFile and tracks.RemoveTrack
|
||||||
|
// wrap it — the mapping has to see through that.
|
||||||
|
func removeFailure(errno syscall.Errno) error {
|
||||||
|
return fmt.Errorf("delete file: %w", &library.FileRemoveError{
|
||||||
|
Path: removeTestPath, UID: 1000, GID: 1000,
|
||||||
|
Err: &fs.PathError{Op: "remove", Path: removeTestPath, Err: errno},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileRemoveAPIError(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
errno syscall.Errno
|
||||||
|
wantStatus int
|
||||||
|
wantCode string
|
||||||
|
wantIn []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "read-only mount", errno: syscall.EROFS,
|
||||||
|
wantStatus: http.StatusConflict, wantCode: "library_not_writable",
|
||||||
|
wantIn: []string{"uid 1000, gid 1000", "/music/Moe Shop/WWW (2020)", "read-only file system", "Nothing was deleted"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "permission denied", errno: syscall.EACCES,
|
||||||
|
wantStatus: http.StatusConflict, wantCode: "library_not_writable",
|
||||||
|
wantIn: []string{"permission denied", "Nothing was deleted"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "operation not permitted", errno: syscall.EPERM,
|
||||||
|
wantStatus: http.StatusConflict, wantCode: "library_not_writable",
|
||||||
|
wantIn: []string{"operation not permitted"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "i/o error", errno: syscall.EIO,
|
||||||
|
wantStatus: http.StatusInternalServerError, wantCode: "file_delete_failed",
|
||||||
|
wantIn: []string{removeTestPath, "input/output error", "Nothing was deleted"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
apiErr, ok := fileRemoveAPIError(removeFailure(tc.errno))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("a wrapped *library.FileRemoveError was not recognised")
|
||||||
|
}
|
||||||
|
if apiErr.Status != tc.wantStatus || apiErr.Code != tc.wantCode {
|
||||||
|
t.Fatalf("got %d %s, want %d %s", apiErr.Status, apiErr.Code, tc.wantStatus, tc.wantCode)
|
||||||
|
}
|
||||||
|
for _, want := range tc.wantIn {
|
||||||
|
if !strings.Contains(apiErr.Message, want) {
|
||||||
|
t.Errorf("message %q lacks %q", apiErr.Message, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The unwritable answer must name the DIRECTORY. Removal needs write access to
|
||||||
|
// the parent, so a message naming the file would send the operator to fix the
|
||||||
|
// wrong permissions. The directory is a prefix of the file path, which is why a
|
||||||
|
// plain "contains the directory" check could never catch that regression.
|
||||||
|
func TestFileRemoveAPIError_NotWritableNamesTheDirectoryNotTheFile(t *testing.T) {
|
||||||
|
apiErr, _ := fileRemoveAPIError(removeFailure(syscall.EROFS))
|
||||||
|
if strings.Contains(apiErr.Message, "01 - WWW.mp3") {
|
||||||
|
t.Fatalf("message names the file rather than its directory: %q", apiErr.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileRemoveAPIError_IgnoresOtherErrors(t *testing.T) {
|
||||||
|
for name, err := range map[string]error{
|
||||||
|
"nil": nil,
|
||||||
|
"plain error": errors.New("delete track: connection reset"),
|
||||||
|
"path error": &fs.PathError{Op: "remove", Path: removeTestPath, Err: syscall.EROFS},
|
||||||
|
"not found": library.ErrTrackNotFound,
|
||||||
|
} {
|
||||||
|
if _, ok := fileRemoveAPIError(err); ok {
|
||||||
|
t.Errorf("%s: mapped as a file-remove failure", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The leveled stream (M464 #5001): a track rendered with the user's loudness
|
||||||
|
// gain applied, for the Sonos and UPnP speakers that fetch their own audio.
|
||||||
|
// See internal/library/leveled.go for how it is rendered.
|
||||||
|
|
||||||
|
// SignLeveledStreamToken signs a leveled stream URL. The gain is part of what
|
||||||
|
// is signed, so a speaker's URL cannot be edited into a different render.
|
||||||
|
// The message cannot collide with SignStreamToken's "<id>|<exp>": a plain
|
||||||
|
// token does not open a leveled stream, nor the reverse.
|
||||||
|
func SignLeveledStreamToken(secret []byte, trackID string, exp int64, g library.LeveledGain) string {
|
||||||
|
mac := hmac.New(sha256.New, secret)
|
||||||
|
lim := 0
|
||||||
|
if g.Limiter {
|
||||||
|
lim = 1
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(mac, "%s|%d|leveled|%d|%d", trackID, exp, g.CentiDB, lim)
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyLeveledStreamToken checks a token from SignLeveledStreamToken, and
|
||||||
|
// that it has not expired.
|
||||||
|
func VerifyLeveledStreamToken(secret []byte, trackID string, exp int64, g library.LeveledGain, token string) bool {
|
||||||
|
if time.Now().Unix() > exp {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return hmac.Equal([]byte(SignLeveledStreamToken(secret, trackID, exp, g)), []byte(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
// leveledStreamPath is the leveled stream's path. It ends in .flac because
|
||||||
|
// Sonos reads the format from the URL's extension (task #610).
|
||||||
|
func leveledStreamPath(trackID pgtype.UUID) string {
|
||||||
|
return "/api/tracks/" + uuidToString(trackID) + "/leveled.flac"
|
||||||
|
}
|
||||||
|
|
||||||
|
// leveledQuery is the query string a leveled URL carries.
|
||||||
|
func leveledQuery(g library.LeveledGain, token string, exp int64) string {
|
||||||
|
lim := "0"
|
||||||
|
if g.Limiter {
|
||||||
|
lim = "1"
|
||||||
|
}
|
||||||
|
return "?g=" + strconv.Itoa(g.CentiDB) + "&lim=" + lim +
|
||||||
|
"&token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// leveledGainFor is the render request for one track under the user's
|
||||||
|
// preference. Unity when leveling is off or the track is unmeasured, which
|
||||||
|
// the caller answers with the plain stream.
|
||||||
|
func (h *handlers) leveledGainFor(ctx context.Context, userID, trackID pgtype.UUID, asAlbum bool) (library.LeveledGain, error) {
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
prefs, err := library.LoadNormalizationPrefs(ctx, q, userID)
|
||||||
|
if err != nil {
|
||||||
|
return library.LeveledGain{}, err
|
||||||
|
}
|
||||||
|
gains, err := library.ReplayGainForTracks(ctx, q, []pgtype.UUID{trackID})
|
||||||
|
if err != nil {
|
||||||
|
return library.LeveledGain{}, err
|
||||||
|
}
|
||||||
|
return library.NewLeveledGain(library.LeveledGainDB(prefs, gains[trackID], asAlbum), prefs.Boost), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLeveledGain reads ?g= and ?lim= from a leveled URL.
|
||||||
|
func parseLeveledGain(r *http.Request) (library.LeveledGain, bool) {
|
||||||
|
c, err := strconv.Atoi(r.URL.Query().Get("g"))
|
||||||
|
if err != nil {
|
||||||
|
return library.LeveledGain{}, false
|
||||||
|
}
|
||||||
|
lim := r.URL.Query().Get("lim")
|
||||||
|
if lim != "0" && lim != "1" {
|
||||||
|
return library.LeveledGain{}, false
|
||||||
|
}
|
||||||
|
g := library.LeveledGain{CentiDB: c, Limiter: lim == "1"}
|
||||||
|
return g, g.Valid()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetLeveledStream implements GET /api/tracks/{id}/leveled.flac. It
|
||||||
|
// accepts a session, like the plain stream, or a leveled token: the gain in
|
||||||
|
// the query must be the one the token was signed for.
|
||||||
|
func (h *handlers) handleGetLeveledStream(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rawID := chi.URLParam(r, "id")
|
||||||
|
g, ok := parseLeveledGain(r)
|
||||||
|
if !h.leveledAuthOk(r, rawID, g, ok) {
|
||||||
|
writeErr(w, apierror.ErrUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_gain", "g and lim must describe a valid gain"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.leveled == nil {
|
||||||
|
writeErr(w, &apierror.Error{Status: http.StatusServiceUnavailable, Code: "leveling_unavailable",
|
||||||
|
Message: "leveled streams are not available on this server"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
track, apiErr := resolveByID(r, "id", dbq.New(h.pool).GetTrackByID, "track")
|
||||||
|
if apiErr != nil {
|
||||||
|
writeErr(w, apiErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path, err := h.leveled.Path(r.Context(), library.LeveledSource{
|
||||||
|
TrackID: rawID, Path: track.FilePath, DurationMs: track.DurationMs,
|
||||||
|
}, g)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, library.ErrLeveledSourceMissing):
|
||||||
|
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "track file not found"})
|
||||||
|
return
|
||||||
|
case r.Context().Err() != nil:
|
||||||
|
return // the speaker hung up; the render carries on for its retry
|
||||||
|
case err != nil:
|
||||||
|
writeErrWithLog(w, h.logger, "leveled stream: render failed", apierror.InternalMsg("render failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
// Evicted between render and open: rare, and the speaker retries.
|
||||||
|
writeErrWithLog(w, h.logger, "leveled stream: open render", apierror.InternalMsg("server error", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "leveled stream: stat render", apierror.InternalMsg("server error", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "audio/flac")
|
||||||
|
w.Header().Set("Accept-Ranges", "bytes")
|
||||||
|
w.Header().Set("Cache-Control", "private, max-age=86400")
|
||||||
|
http.ServeContent(w, r, "leveled.flac", info.ModTime(), f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// leveledAuthOk mirrors streamAuthOk: a session, or a token signed over this
|
||||||
|
// track and this gain. A malformed gain fails the token path, since there is
|
||||||
|
// nothing it could have been signed over.
|
||||||
|
func (h *handlers) leveledAuthOk(r *http.Request, trackID string, g library.LeveledGain, gainOK bool) bool {
|
||||||
|
if _, ok := auth.UserFromContext(r.Context()); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !gainOK {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
tok := r.URL.Query().Get("token")
|
||||||
|
exp, err := strconv.ParseInt(r.URL.Query().Get("exp"), 10, 64)
|
||||||
|
if tok == "" || err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return VerifyLeveledStreamToken(h.streamSecret, trackID, exp, g, tok)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user