Merge pull request 'M462 security hardening and M464 loudness analysis (steps 1–3)' (#135) from dev into main
release / go (push) Successful in 2m19s
release / web (push) Successful in 1m43s
release / govulncheck (push) Successful in 35s
release / Build signed APK (releases and dev) (push) Skipped
release / android (push) Successful in 6m10s
release / integration (push) Successful in 19m35s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 2m24s
release / Verify release artifacts (tag releases only) (push) Skipped

This commit was merged in pull request #135.
This commit is contained in:
2026-10-06 14:19:58 -04:00
146 changed files with 7421 additions and 783 deletions
-92
View File
@@ -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
+356 -18
View File
@@ -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,276 @@ 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).
MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate
go test -p 1 -race ./...
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
- 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
# 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
@@ -245,21 +544,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 +638,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 +861,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 +892,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:
-156
View File
@@ -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 ./...
-44
View File
@@ -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
+1 -1
View File
@@ -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
+9 -3
View File
@@ -34,6 +34,9 @@ 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
@@ -76,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>
@@ -100,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:
@@ -154,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
@@ -75,6 +75,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.",
@@ -5,11 +5,17 @@ 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 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 +33,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 +53,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)
@@ -64,10 +79,20 @@ class AuthStore @Inject constructor(
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
@@ -85,9 +110,50 @@ class AuthStore @Inject constructor(
}.getOrDefault(CacheSettings.DEFAULT) }.getOrDefault(CacheSettings.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 +187,7 @@ class AuthStore @Inject constructor(
scope.launch { persistDiagnosticsOptOut(value) } scope.launch { persistDiagnosticsOptOut(value) }
} }
private suspend fun persistCookie(value: String?) { 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 {
@@ -179,7 +245,9 @@ class AuthStore @Inject constructor(
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,
@@ -193,6 +261,10 @@ class AuthStore @Inject constructor(
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
} }
} }
@@ -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,
@@ -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,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) }
}
}
+9 -8
View File
@@ -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
View File
@@ -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)
+18
View File
@@ -132,6 +132,13 @@ func run() error {
} }
scanner := library.New(pool, logger, cfg.Library.ScanPaths, fpSettings) 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)
}
contact := cfg.Library.ContactEmail contact := cfg.Library.ContactEmail
if contact == "" { if contact == "" {
contact = "https://git.fabledsword.com/bvandeusen/minstrel" contact = "https://git.fabledsword.com/bvandeusen/minstrel"
@@ -228,6 +235,11 @@ func run() error {
// internal/library/fingerprint_backfill.go for why. // internal/library/fingerprint_backfill.go for why.
go library.NewFingerprintBackfillWorker(pool, logger.With("component", "fingerprint_backfill"), fpSettings).Run(ctx) 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)
// Duplicate sweep (M400 #3910): proposes groups of tracks holding one // Duplicate sweep (M400 #3910): proposes groups of tracks holding one
// recording, from the fingerprints above. Sweeps only when fingerprints have // recording, from the fingerprints above. Sweeps only when fingerprints have
// changed since the last sweep. // changed since the last sweep.
@@ -377,6 +389,7 @@ func run() error {
srv.RecSettings = recSettings srv.RecSettings = recSettings
srv.TagSettings = tagSettings srv.TagSettings = tagSettings
srv.FingerprintSettings = fpSettings srv.FingerprintSettings = fpSettings
srv.LoudnessSettings = loudSettings
// The sweeper above holds this same instance, so a save from the admin // The sweeper above holds this same instance, so a save from the admin
// card changes what it does on its next tick (#3936). // card changes what it does on its next tick (#3936).
srv.ReacqSettings = reacqSettings srv.ReacqSettings = reacqSettings
@@ -385,6 +398,11 @@ func run() error {
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
View File
@@ -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
+92
View File
@@ -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.
+105
View File
@@ -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.
+2 -2
View File
@@ -25,7 +25,7 @@ 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/sync v0.21.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
) )
+4 -4
View File
@@ -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=
+82
View File
@@ -0,0 +1,82 @@
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"`
}
func loudnessSettingsBodyOf(s library.LoudnessSettings) loudnessSettingsBody {
return loudnessSettingsBody{Enabled: s.Enabled, BackfillConcurrency: s.BackfillConcurrency}
}
// 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,
})
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))
}
+37 -6
View File
@@ -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(),
} }
} }
+14 -1
View File
@@ -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)
+44 -2
View File
@@ -7,6 +7,7 @@ package api
import ( import (
"log/slog" "log/slog"
"math/rand" "math/rand"
"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,8 +34,15 @@ 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, fpSettings *library.FingerprintSettingsService) { 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) {
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,
@@ -57,12 +65,21 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
netSettings: netSettings, netSettings: netSettings,
reacqSettings: reacqSettings, reacqSettings: reacqSettings,
fingerprintSettings: fpSettings, fingerprintSettings: fpSettings,
loudnessSettings: loudSettings,
librarySize: recommendation.NewLibrarySize(nil), 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)
@@ -92,8 +109,10 @@ 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.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)
@@ -113,6 +132,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.
@@ -219,6 +241,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Get("/library/fingerprints", h.handleGetFingerprintCoverage) admin.Get("/library/fingerprints", h.handleGetFingerprintCoverage)
admin.Get("/library/fingerprint-settings", h.handleGetFingerprintSettings) admin.Get("/library/fingerprint-settings", h.handleGetFingerprintSettings)
admin.Put("/library/fingerprint-settings", h.handleUpdateFingerprintSettings) 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)
// Duplicates report (#3912): proposals from the duplicate sweep, a // Duplicates report (#3912): proposals from the duplicate sweep, a
// trigger to sweep now, dismissal, and the merge (#3911), which deletes // trigger to sweep now, dismissal, and the merge (#3911), which deletes
// the removed copies' files after moving their history onto the kept one. // the removed copies' files after moving their history onto the kept one.
@@ -313,6 +338,23 @@ type handlers struct {
// instance the scanner and the fingerprint workers read, so a save from the // instance the scanner and the fingerprint workers read, so a save from the
// admin card reaches them without a restart. Nil serves the defaults. // admin card reaches them without a restart. Nil serves the defaults.
fingerprintSettings *library.FingerprintSettingsService 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
// 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
+30 -2
View File
@@ -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
View File
@@ -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
} }
+57 -1
View File
@@ -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(),
+62 -6
View File
@@ -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())
}
+72
View File
@@ -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")
}
}
+19
View File
@@ -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)
+31 -1
View File
@@ -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",
+14
View File
@@ -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) {
+11 -2
View File
@@ -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/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
) )
@@ -151,8 +152,12 @@ func (h *handlers) hydrateUpserts(
if err != nil { if err != nil {
return nil, err return nil, err
} }
gains, err := library.ReplayGainForAlbums(ctx, q, uuids)
if err != nil {
return nil, err
}
for _, a := range albums { for _, a := range albums {
b, _ := json.Marshal(toAlbumSyncView(a)) b, _ := json.Marshal(toAlbumSyncView(a, gains[a.ID]))
out["album"] = append(out["album"], b) out["album"] = append(out["album"], b)
} }
} }
@@ -162,8 +167,12 @@ func (h *handlers) hydrateUpserts(
if err != nil { if err != nil {
return nil, err return nil, err
} }
gains, err := library.ReplayGainForTracks(ctx, q, uuids)
if err != nil {
return nil, err
}
for _, t := range tracks { for _, t := range tracks {
b, _ := json.Marshal(toTrackSyncView(t)) b, _ := json.Marshal(toTrackSyncView(t, gains[t.ID]))
out["track"] = append(out["track"], b) out["track"] = append(out["track"], b)
} }
} }
+17 -2
View File
@@ -18,6 +18,7 @@ package api
import ( import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
) )
@@ -49,9 +50,14 @@ type albumSyncView struct {
ReleaseDate *string `json:"release_date"` ReleaseDate *string `json:"release_date"`
CoverArtPath *string `json:"cover_art_path"` CoverArtPath *string `json:"cover_art_path"`
Mbid *string `json:"mbid"` Mbid *string `json:"mbid"`
// AlbumGain and AlbumPeak are the album's ReplayGain 2.0 values (#4997):
// dB to the -18 LUFS reference, and a linear peak. Null until every track
// on the album is measured.
AlbumGain *float32 `json:"album_gain"`
AlbumPeak *float32 `json:"album_peak"`
} }
func toAlbumSyncView(a dbq.Album) albumSyncView { func toAlbumSyncView(a dbq.Album, g library.ReplayGain) albumSyncView {
var releaseDate *string var releaseDate *string
if a.ReleaseDate.Valid { if a.ReleaseDate.Valid {
s := a.ReleaseDate.Time.Format("2006-01-02") s := a.ReleaseDate.Time.Format("2006-01-02")
@@ -65,6 +71,8 @@ func toAlbumSyncView(a dbq.Album) albumSyncView {
ReleaseDate: releaseDate, ReleaseDate: releaseDate,
CoverArtPath: a.CoverArtPath, CoverArtPath: a.CoverArtPath,
Mbid: a.Mbid, Mbid: a.Mbid,
AlbumGain: g.AlbumGain,
AlbumPeak: g.AlbumPeak,
} }
} }
@@ -92,9 +100,14 @@ type trackSyncView struct {
// track is playable, which is a yes/no. The "gone since" clock is an // track is playable, which is a yes/no. The "gone since" clock is an
// operator concern and lives on the admin surface. // operator concern and lives on the admin surface.
Missing bool `json:"missing"` Missing bool `json:"missing"`
// TrackGain and TrackPeak are the track's ReplayGain 2.0 values (#4997).
// Null until the track is measured. The album's pair rides on the album
// view, since it changes when the album does, not when this track does.
TrackGain *float32 `json:"track_gain"`
TrackPeak *float32 `json:"track_peak"`
} }
func toTrackSyncView(t dbq.Track) trackSyncView { func toTrackSyncView(t dbq.Track, g library.ReplayGain) trackSyncView {
return trackSyncView{ return trackSyncView{
ID: syncpkg.FormatUUID(t.ID), ID: syncpkg.FormatUUID(t.ID),
AlbumID: syncpkg.FormatUUID(t.AlbumID), AlbumID: syncpkg.FormatUUID(t.AlbumID),
@@ -107,6 +120,8 @@ func toTrackSyncView(t dbq.Track) trackSyncView {
FileFormat: t.FileFormat, FileFormat: t.FileFormat,
Genre: t.Genre, Genre: t.Genre,
Missing: t.MissingSince.Valid, Missing: t.MissingSince.Valid,
TrackGain: g.TrackGain,
TrackPeak: g.TrackPeak,
} }
} }
+34 -6
View File
@@ -8,6 +8,7 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
) )
// These tests pin the wire-format keys for /api/library/sync upserts. // These tests pin the wire-format keys for /api/library/sync upserts.
@@ -53,13 +54,13 @@ func TestArtistSyncView_WireKeys(t *testing.T) {
func TestAlbumSyncView_WireKeys(t *testing.T) { func TestAlbumSyncView_WireKeys(t *testing.T) {
a := dbq.Album{ID: validUUID, ArtistID: validUUID, Title: "Drukqs", SortTitle: "Drukqs"} a := dbq.Album{ID: validUUID, ArtistID: validUUID, Title: "Drukqs", SortTitle: "Drukqs"}
b, err := json.Marshal(toAlbumSyncView(a)) b, err := json.Marshal(toAlbumSyncView(a, library.ReplayGain{}))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
assertJSONKeys(t, "album", b, []string{ assertJSONKeys(t, "album", b, []string{
"id", "artist_id", "title", "sort_title", "id", "artist_id", "title", "sort_title",
"release_date", "cover_art_path", "mbid", "release_date", "cover_art_path", "mbid", "album_gain", "album_peak",
}) })
} }
@@ -69,14 +70,14 @@ func TestTrackSyncView_WireKeys(t *testing.T) {
Title: "Avril 14th", DurationMs: 121_000, Title: "Avril 14th", DurationMs: 121_000,
FilePath: "x", FileFormat: "flac", FilePath: "x", FileFormat: "flac",
} }
b, err := json.Marshal(toTrackSyncView(tr)) b, err := json.Marshal(toTrackSyncView(tr, library.ReplayGain{}))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
assertJSONKeys(t, "track", b, []string{ assertJSONKeys(t, "track", b, []string{
"id", "album_id", "artist_id", "title", "duration_ms", "id", "album_id", "artist_id", "title", "duration_ms",
"track_number", "disc_number", "file_path", "file_format", "genre", "track_number", "disc_number", "file_path", "file_format", "genre",
"missing", "missing", "track_gain", "track_peak",
}) })
} }
@@ -85,17 +86,44 @@ func TestTrackSyncView_WireKeys(t *testing.T) {
// client's library, a missing one stays playable and fails at the speaker. // client's library, a missing one stays playable and fails at the speaker.
func TestTrackSyncView_MissingReflectsTheMark(t *testing.T) { func TestTrackSyncView_MissingReflectsTheMark(t *testing.T) {
present := dbq.Track{ID: validUUID, AlbumID: validUUID, ArtistID: validUUID} present := dbq.Track{ID: validUUID, AlbumID: validUUID, ArtistID: validUUID}
if toTrackSyncView(present).Missing { if toTrackSyncView(present, library.ReplayGain{}).Missing {
t.Error("a track with no missing_since must not be marked missing") t.Error("a track with no missing_since must not be marked missing")
} }
gone := present gone := present
gone.MissingSince = pgtype.Timestamptz{Time: time.Now(), Valid: true} gone.MissingSince = pgtype.Timestamptz{Time: time.Now(), Valid: true}
if !toTrackSyncView(gone).Missing { if !toTrackSyncView(gone, library.ReplayGain{}).Missing {
t.Error("a track with missing_since must be marked missing") t.Error("a track with missing_since must be marked missing")
} }
} }
// The gains are what Android levels playback by, offline included (#4997):
// the track's pair rides on the track view and the album's on the album view.
func TestSyncViews_CarryReplayGain(t *testing.T) {
gain, peak := float32(-3.25), float32(0.9441)
g := library.ReplayGain{TrackGain: &gain, TrackPeak: &peak, AlbumGain: &gain, AlbumPeak: &peak}
tv := toTrackSyncView(dbq.Track{ID: validUUID, AlbumID: validUUID, ArtistID: validUUID}, g)
if tv.TrackGain == nil || *tv.TrackGain != gain || tv.TrackPeak == nil || *tv.TrackPeak != peak {
t.Errorf("track view gains = %v/%v, want %v/%v", tv.TrackGain, tv.TrackPeak, gain, peak)
}
av := toAlbumSyncView(dbq.Album{ID: validUUID, ArtistID: validUUID}, g)
if av.AlbumGain == nil || *av.AlbumGain != gain || av.AlbumPeak == nil || *av.AlbumPeak != peak {
t.Errorf("album view gains = %v/%v, want %v/%v", av.AlbumGain, av.AlbumPeak, gain, peak)
}
// Unmeasured: explicit nulls, like every other optional sync field.
b, err := json.Marshal(toTrackSyncView(dbq.Track{ID: validUUID, AlbumID: validUUID, ArtistID: validUUID}, library.ReplayGain{}))
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(b, &m); err != nil {
t.Fatal(err)
}
if v, ok := m["track_gain"]; !ok || v != nil {
t.Errorf("unmeasured track_gain = %v (present %v), want an explicit null", v, ok)
}
}
func TestPlaylistSyncView_WireKeys(t *testing.T) { func TestPlaylistSyncView_WireKeys(t *testing.T) {
variant := "discover" variant := "discover"
p := dbq.Playlist{ p := dbq.Playlist{
+1 -1
View File
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
r := chi.NewRouter() r := chi.NewRouter()
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)), w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
30*time.Minute, 0.5, 30000) 30*time.Minute, 0.5, 30000)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings, nil, nil) Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings, nil, nil, nil)
paths := []string{ paths := []string{
"/api/artists", "/api/artists",
+14
View File
@@ -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/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"
) )
@@ -61,6 +62,19 @@ func (h *handlers) handleChangePassword(w http.ResponseWriter, r *http.Request)
return return
} }
// Sign out every other device. A password change is often the response
// to suspecting someone else has it, and their session would otherwise
// outlive the password it was opened with. The device making the change
// stays signed in.
if sessID, ok := auth.SessionIDFromContext(r.Context()); ok {
if _, err := q.DeleteOtherSessionsForUser(r.Context(), dbq.DeleteOtherSessionsForUserParams{
UserID: user.ID,
ID: sessID,
}); err != nil {
h.logger.Error("change password: revoke other sessions failed", "err", err)
}
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionPasswordChangeSelf, nil) audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionPasswordChangeSelf, nil)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
+84
View File
@@ -0,0 +1,84 @@
package api
import (
"crypto/rand"
"encoding/base64"
"net/http"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// The Subsonic password is for clients that sign in with Subsonic's t/s
// scheme (md5 of the password plus a salt). Checking that needs the password
// itself, so it is stored readable (migration 0003). That is why Minstrel
// generates it rather than letting the user choose one: a generated value
// can never be a login password reused from somewhere else, so a leaked
// users table gives up access to this server's /rest API and nothing more
// (M462 #5026).
const subsonicPasswordBytes = 18 // 24 base64url characters
type subsonicPasswordStatusResp struct {
Enabled bool `json:"enabled"`
}
type subsonicPasswordResp struct {
Password string `json:"password"`
}
// handleGetMySubsonicPassword implements GET /api/me/subsonic-password. It
// reports only whether one is set; the value is shown once, when generated.
func (h *handlers) handleGetMySubsonicPassword(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, subsonicPasswordStatusResp{Enabled: user.SubsonicPassword != nil})
}
// handleGenerateMySubsonicPassword implements POST /api/me/subsonic-password:
// replaces any existing Subsonic password with a new random one and returns it.
func (h *handlers) handleGenerateMySubsonicPassword(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
b := make([]byte, subsonicPasswordBytes)
if _, err := rand.Read(b); err != nil {
h.logger.Error("generate subsonic password: rand failed", "err", err)
writeErr(w, apierror.Internal(err))
return
}
pw := base64.RawURLEncoding.EncodeToString(b)
if err := dbq.New(h.pool).SetSubsonicPassword(r.Context(), dbq.SetSubsonicPasswordParams{
ID: user.ID,
SubsonicPassword: &pw,
}); err != nil {
h.logger.Error("generate subsonic password: update failed", "err", err)
writeErr(w, apierror.Internal(err))
return
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionSubsonicPasswordSet, nil)
writeJSON(w, http.StatusOK, subsonicPasswordResp{Password: pw})
}
// handleClearMySubsonicPassword implements DELETE /api/me/subsonic-password,
// which turns t/s and p= sign-in off for the account.
func (h *handlers) handleClearMySubsonicPassword(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
if err := dbq.New(h.pool).SetSubsonicPassword(r.Context(), dbq.SetSubsonicPasswordParams{
ID: user.ID,
SubsonicPassword: nil,
}); err != nil {
h.logger.Error("clear subsonic password: update failed", "err", err)
writeErr(w, apierror.Internal(err))
return
}
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionSubsonicPasswordClear, nil)
w.WriteHeader(http.StatusNoContent)
}
+132
View File
@@ -0,0 +1,132 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func newMeSubsonicPasswordRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Get("/api/me/subsonic-password", h.handleGetMySubsonicPassword)
r.Post("/api/me/subsonic-password", h.handleGenerateMySubsonicPassword)
r.Delete("/api/me/subsonic-password", h.handleClearMySubsonicPassword)
return r
}
func readSubsonicPassword(t *testing.T, h *handlers, user dbq.User) *string {
t.Helper()
var pw *string
if err := h.pool.QueryRow(context.Background(),
"SELECT subsonic_password FROM users WHERE id = $1", user.ID).Scan(&pw); err != nil {
t.Fatalf("read subsonic_password: %v", err)
}
return pw
}
func TestSubsonicPassword_GenerateIsRandomAndNotTheLoginPassword(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
user := seedUser(t, pool, "sspw1", "login-pw", false)
router := newMeSubsonicPasswordRouter(h)
generate := func() string {
req := withUser(httptest.NewRequest(http.MethodPost, "/api/me/subsonic-password", nil), user)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp subsonicPasswordResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
return resp.Password
}
first := generate()
if len(first) != 24 {
t.Errorf("password length = %d, want 24", len(first))
}
if first == "login-pw" {
t.Errorf("generated password equals the login password")
}
if got := readSubsonicPassword(t, h, user); got == nil || *got != first {
t.Errorf("stored = %v, want the returned password", got)
}
second := generate()
if second == first {
t.Errorf("regenerate returned the same password")
}
if got := readSubsonicPassword(t, h, user); got == nil || *got != second {
t.Errorf("stored after regenerate = %v, want the new password", got)
}
}
func TestSubsonicPassword_StatusAndClear(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
user := seedUser(t, pool, "sspw2", "login-pw", false)
router := newMeSubsonicPasswordRouter(h)
status := func(u dbq.User) bool {
req := withUser(httptest.NewRequest(http.MethodGet, "/api/me/subsonic-password", nil), u)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp subsonicPasswordStatusResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
return resp.Enabled
}
if status(user) {
t.Errorf("enabled = true for a new account, want false")
}
pw := "set-by-test"
user.SubsonicPassword = &pw
if !status(user) {
t.Errorf("enabled = false with a password set, want true")
}
// The status response never carries the value itself.
req := withUser(httptest.NewRequest(http.MethodGet, "/api/me/subsonic-password", nil), user)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
var raw map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil {
t.Fatalf("decode: %v", err)
}
if _, has := raw["password"]; has {
t.Errorf("GET response includes the password: %s", rec.Body.String())
}
if err := dbq.New(pool).SetSubsonicPassword(context.Background(), dbq.SetSubsonicPasswordParams{
ID: user.ID, SubsonicPassword: &pw,
}); err != nil {
t.Fatalf("seed subsonic_password: %v", err)
}
req = withUser(httptest.NewRequest(http.MethodDelete, "/api/me/subsonic-password", nil), user)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE status = %d, want 204; body=%s", rec.Code, rec.Body.String())
}
if got := readSubsonicPassword(t, h, user); got != nil {
t.Errorf("stored after clear = %q, want NULL", *got)
}
}
+9 -22
View File
@@ -13,23 +13,11 @@ type apiTokenResp struct {
APIToken string `json:"api_token"` APIToken string `json:"api_token"`
} }
// handleGetMyAPIToken implements GET /api/me/api-token.
func (h *handlers) handleGetMyAPIToken(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
q := dbq.New(h.pool)
current, err := q.GetUserByID(r.Context(), user.ID)
if err != nil {
h.logger.Error("get api token: lookup failed", "err", err)
writeErr(w, apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, apiTokenResp{APIToken: current.ApiToken})
}
// handleRegenerateMyAPIToken implements POST /api/me/api-token. // handleRegenerateMyAPIToken implements POST /api/me/api-token.
//
// Only the key's hash is stored, so this response is the one time the key
// can be read. There is deliberately no GET: a key that has been shown and
// lost is replaced, not looked up.
func (h *handlers) handleRegenerateMyAPIToken(w http.ResponseWriter, r *http.Request) { func (h *handlers) handleRegenerateMyAPIToken(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r) user, ok := requireUser(w, r)
if !ok { if !ok {
@@ -42,11 +30,10 @@ func (h *handlers) handleRegenerateMyAPIToken(w http.ResponseWriter, r *http.Req
return return
} }
q := dbq.New(h.pool) q := dbq.New(h.pool)
updated, err := q.RegenerateApiToken(r.Context(), dbq.RegenerateApiTokenParams{ if err := q.RegenerateApiToken(r.Context(), dbq.RegenerateApiTokenParams{
ID: user.ID, ID: user.ID,
ApiToken: newToken, ApiTokenHash: auth.HashAPIToken(newToken),
}) }); err != nil {
if err != nil {
h.logger.Error("regenerate api token: update failed", "err", err) h.logger.Error("regenerate api token: update failed", "err", err)
writeErr(w, apierror.Internal(err)) writeErr(w, apierror.Internal(err))
return return
@@ -54,5 +41,5 @@ func (h *handlers) handleRegenerateMyAPIToken(w http.ResponseWriter, r *http.Req
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionTokenRegenerate, nil) audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionTokenRegenerate, nil)
writeJSON(w, http.StatusOK, apiTokenResp{APIToken: updated.ApiToken}) writeJSON(w, http.StatusOK, apiTokenResp{APIToken: newToken})
} }
+14 -38
View File
@@ -8,47 +8,23 @@ import (
"os" "os"
"testing" "testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
func newMeTokenRouter(h *handlers) chi.Router { func newMeTokenRouter(h *handlers) chi.Router {
r := chi.NewRouter() r := chi.NewRouter()
r.Get("/api/me/api-token", h.handleGetMyAPIToken)
r.Post("/api/me/api-token", h.handleRegenerateMyAPIToken) r.Post("/api/me/api-token", h.handleRegenerateMyAPIToken)
return r return r
} }
func TestGetAPIToken_ReturnsCurrentToken(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
user := seedUser(t, pool, "tok1", "pw", false)
req := httptest.NewRequest(http.MethodGet, "/api/me/api-token", nil)
req = withUser(req, user)
rec := httptest.NewRecorder()
newMeTokenRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp apiTokenResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.APIToken != user.ApiToken {
t.Errorf("api_token = %q, want %q", resp.APIToken, user.ApiToken)
}
}
func TestRegenerateAPIToken_IssuesNewToken(t *testing.T) { func TestRegenerateAPIToken_IssuesNewToken(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")
} }
h, pool := testHandlers(t) h, pool := testHandlers(t)
user := seedUser(t, pool, "tok2", "pw", false) user := seedUser(t, pool, "tok2", "pw", false)
oldToken := user.ApiToken oldHash := user.ApiTokenHash
req := httptest.NewRequest(http.MethodPost, "/api/me/api-token", nil) req := httptest.NewRequest(http.MethodPost, "/api/me/api-token", nil)
req = withUser(req, user) req = withUser(req, user)
@@ -65,20 +41,20 @@ func TestRegenerateAPIToken_IssuesNewToken(t *testing.T) {
if resp.APIToken == "" { if resp.APIToken == "" {
t.Fatalf("api_token is empty") t.Fatalf("api_token is empty")
} }
if resp.APIToken == oldToken { // The DB holds the new key's hash, never the key, and the old key no
t.Errorf("api_token unchanged after regenerate") // longer matches.
} var dbHash string
// Verify DB row has the new token and old token no longer matches.
var dbToken string
if err := pool.QueryRow(context.Background(), if err := pool.QueryRow(context.Background(),
"SELECT api_token FROM users WHERE id = $1", user.ID).Scan(&dbToken); err != nil { "SELECT api_token_hash FROM users WHERE id = $1", user.ID).Scan(&dbHash); err != nil {
t.Fatalf("read token: %v", err) t.Fatalf("read token hash: %v", err)
} }
if dbToken != resp.APIToken { if dbHash != auth.HashAPIToken(resp.APIToken) {
t.Errorf("DB api_token = %q, want %q", dbToken, resp.APIToken) t.Errorf("DB api_token_hash = %q, want hash of the returned key", dbHash)
} }
if dbToken == oldToken { if dbHash == oldHash {
t.Errorf("old token still in DB after regenerate") t.Errorf("old key hash still in DB after regenerate")
}
if dbHash == resp.APIToken {
t.Errorf("DB holds the raw key")
} }
} }
+2 -2
View File
@@ -117,7 +117,7 @@ func (h *handlers) handleGetCover(w http.ResponseWriter, r *http.Request) {
// clients skip the conditional GET for the bulk of a session, but // clients skip the conditional GET for the bulk of a session, but
// stale art clears within 24h after a re-scan. ServeContent below // stale art clears within 24h after a re-scan. ServeContent below
// still emits Last-Modified for the conditional path when needed. // still emits Last-Modified for the conditional path when needed.
w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate") w.Header().Set("Cache-Control", "private, max-age=86400, must-revalidate")
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f) http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
} }
@@ -197,6 +197,6 @@ func (h *handlers) handleGetStream(w http.ResponseWriter, r *http.Request) {
// max-age + immutable lets the client cache (LockCachingAudioSource // max-age + immutable lets the client cache (LockCachingAudioSource
// on the Flutter side, browser cache on web) skip even the // on the Flutter side, browser cache on web) skip even the
// conditional GET on repeat plays. // conditional GET on repeat plays.
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f) http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)
} }
+1 -1
View File
@@ -406,7 +406,7 @@ func (h *handlers) handleGetPlaylistCover(w http.ResponseWriter, r *http.Request
// (system playlists re-rendered on rebuild, user playlists when // (system playlists re-rendered on rebuild, user playlists when
// modified). 5 minutes is short enough for normal edits to feel // modified). 5 minutes is short enough for normal edits to feel
// fresh, long enough to skip repeat fetches during a session. // fresh, long enough to skip repeat fetches during a session.
w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate") w.Header().Set("Cache-Control", "private, max-age=300, must-revalidate")
http.ServeFile(w, r, full) http.ServeFile(w, r, full)
} }
+54
View File
@@ -0,0 +1,54 @@
package api
import (
"net/http"
"strings"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
// maxReplayGainIDs caps one lookup. A player asks for the tracks around the
// one it is about to play, never a whole library.
const maxReplayGainIDs = 200
// replayGainResp is the wire shape for GET /api/tracks/replay-gain. Tracks
// with no gain known are absent from items: no adjustment.
type replayGainResp struct {
Items map[string]library.ReplayGain `json:"items"`
}
// handleGetReplayGain implements GET /api/tracks/replay-gain?ids=a,b,c (M464
// #4997): ReplayGain 2.0 values (dB to -18 LUFS, linear peaks) for each track,
// and for its album when the whole album is measured.
//
// A lookup rather than fields on TrackRef. Tracks reach the player through
// about fifteen surfaces in two wire shapes (TrackRef and playlist entries),
// and only the player uses the gain. One call from the player, made for the
// tracks it is about to play, cannot be forgotten by a surface added later,
// and costs nothing on the pages that never play anything. Android takes the
// same values from the sync feed instead, so they are there offline.
func (h *handlers) handleGetReplayGain(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimSpace(r.URL.Query().Get("ids"))
if raw == "" {
writeJSON(w, http.StatusOK, replayGainResp{Items: map[string]library.ReplayGain{}})
return
}
parts := strings.Split(raw, ",")
if len(parts) > maxReplayGainIDs {
writeErr(w, apierror.BadRequest("too_many_ids", "at most 200 ids per request"))
return
}
ids := stringsToUUIDs(parts)
gains, err := library.ReplayGainForTracks(r.Context(), dbq.New(h.pool), ids)
if err != nil {
writeErrWithLog(w, h.logger, "replay gain: lookup", apierror.InternalMsg("lookup failed", err))
return
}
items := make(map[string]library.ReplayGain, len(gains))
for id, g := range gains {
items[uuidToString(id)] = g
}
writeJSON(w, http.StatusOK, replayGainResp{Items: items})
}
+70
View File
@@ -0,0 +1,70 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func TestGetReplayGain(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "rg1", "pw", false)
measured, _ := seedTrackForRemoveTest(t, h, "rg-measured", "", "")
unmeasured, _ := seedTrackForRemoveTest(t, h, "rg-unmeasured", "", "")
lufs, peak := float32(-12.5), float32(-1)
if err := dbq.New(pool).UpsertTrackLoudness(context.Background(), dbq.UpsertTrackLoudnessParams{
TrackID: measured.ID, IntegratedLufs: &lufs, TruePeakDbtp: &peak, AnalysisVersion: 1,
}); err != nil {
t.Fatalf("seed loudness: %v", err)
}
r := chi.NewRouter()
r.Get("/api/tracks/replay-gain", h.handleGetReplayGain)
get := func(query string) *httptest.ResponseRecorder {
req := withUser(httptest.NewRequest(http.MethodGet, "/api/tracks/replay-gain"+query, nil), user)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
return rec
}
rec := get("?ids=" + uuidToString(measured.ID) + "," + uuidToString(unmeasured.ID) + ",not-a-uuid")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Items map[string]struct {
TrackGain *float32 `json:"track_gain"`
TrackPeak *float32 `json:"track_peak"`
AlbumGain *float32 `json:"album_gain"`
} `json:"items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
g, ok := resp.Items[uuidToString(measured.ID)]
if !ok || g.TrackGain == nil || *g.TrackGain != -5.5 || g.TrackPeak == nil {
t.Errorf("measured track = %+v (present %v), want track_gain -5.5 with a peak", g, ok)
}
if g.AlbumGain != nil {
t.Errorf("album_gain present with no album loudness: %v", *g.AlbumGain)
}
if _, ok := resp.Items[uuidToString(unmeasured.ID)]; ok {
t.Errorf("unmeasured track listed; absent means no adjustment")
}
if len(resp.Items) != 1 {
t.Errorf("items = %v, want only the measured track", resp.Items)
}
if rec := get(""); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"items":{}`) {
t.Errorf("no ids: %d %s, want 200 with empty items", rec.Code, rec.Body.String())
}
if rec := get("?ids=" + strings.Repeat("x,", maxReplayGainIDs)); rec.Code != http.StatusBadRequest {
t.Errorf("%d ids: status %d, want 400", maxReplayGainIDs+1, rec.Code)
}
}
+139
View File
@@ -0,0 +1,139 @@
package api
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func sessionExists(t *testing.T, pool *pgxpool.Pool, id pgtype.UUID) bool {
t.Helper()
var exists bool
if err := pool.QueryRow(context.Background(),
`SELECT EXISTS (SELECT 1 FROM sessions WHERE id = $1)`, id,
).Scan(&exists); err != nil {
t.Fatalf("exists check: %v", err)
}
return exists
}
// Sessions expire server-side on two limits, and an expired one stops
// authenticating at once rather than when the GC sweep gets to it.
func TestSessionExpiry_IdleAndAbsoluteLimits(t *testing.T) {
_, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", false)
q := dbq.New(pool)
mint := func(setup string) []byte {
token, err := auth.MintSessionToken()
if err != nil {
t.Fatalf("mint: %v", err)
}
hash := auth.HashSessionToken(token)
sess, err := q.InsertSession(context.Background(), dbq.InsertSessionParams{
UserID: user.ID, TokenHash: hash, UserAgent: "test", Ip: "192.0.2.1",
})
if err != nil {
t.Fatalf("insert: %v", err)
}
if setup != "" {
if _, err := pool.Exec(context.Background(), `UPDATE sessions SET `+setup+` WHERE id = $1`, sess.ID); err != nil {
t.Fatalf("age session: %v", err)
}
}
return hash
}
fresh := mint("")
idle := mint(`last_seen_at = now() - interval '31 days'`)
old := mint(`created_at = now() - interval '366 days'`)
if _, err := q.GetSessionByTokenHash(context.Background(), fresh); err != nil {
t.Errorf("fresh session: %v, want found", err)
}
for name, hash := range map[string][]byte{"idle": idle, "absolute": old} {
if _, err := q.GetSessionByTokenHash(context.Background(), hash); !errors.Is(err, pgx.ErrNoRows) {
t.Errorf("%s-expired session: err = %v, want ErrNoRows", name, err)
}
}
listed, err := q.ListSessionsForUser(context.Background(), user.ID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(listed) != 1 {
t.Errorf("listed %d sessions, want only the live one", len(listed))
}
swept, err := q.GcDeleteExpiredSessions(context.Background())
if err != nil {
t.Fatalf("gc: %v", err)
}
if swept != 2 {
t.Errorf("gc swept %d, want the 2 expired rows", swept)
}
}
// Changing your password signs out every other device and keeps this one.
func TestHandleChangePassword_RevokesOtherSessions(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", false)
current := seedSession(t, pool, user.ID, "192.0.2.1")
other := seedSession(t, pool, user.ID, "198.51.100.7")
body := strings.NewReader(`{"current_password":"hunter2","new_password":"correct-horse"}`)
req := httptest.NewRequest(http.MethodPut, "/api/me/password", body)
req.Header.Set("Content-Type", "application/json")
req = withSession(req, user, current)
w := httptest.NewRecorder()
h.handleChangePassword(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
if !sessionExists(t, pool, current) {
t.Error("the device that changed the password was signed out")
}
if sessionExists(t, pool, other) {
t.Error("another device's session survived the password change")
}
}
// A reset by email ends every session the account has.
func TestHandleResetPassword_RevokesAllSessions(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", false)
s1 := seedSession(t, pool, user.ID, "192.0.2.1")
s2 := seedSession(t, pool, user.ID, "198.51.100.7")
const token = "reset-token-for-session-lifecycle-test"
if _, err := pool.Exec(context.Background(),
`INSERT INTO password_resets (token, user_id, expires_at) VALUES ($1, $2, now() + interval '1 hour')`,
token, user.ID,
); err != nil {
t.Fatalf("seed reset: %v", err)
}
body := strings.NewReader(`{"token":"` + token + `","new_password":"correct-horse"}`)
req := httptest.NewRequest(http.MethodPost, "/api/auth/reset-password", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleResetPassword(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
if sessionExists(t, pool, s1) || sessionExists(t, pool, s2) {
t.Error("a session survived the password reset")
}
}
+4
View File
@@ -54,6 +54,10 @@ func Unauthorized(code, message string) *Error {
return &Error{Status: 401, Code: code, Message: message} return &Error{Status: 401, Code: code, Message: message}
} }
func TooManyRequests(code, message string) *Error {
return &Error{Status: 429, Code: code, Message: message}
}
func Internal(cause error) *Error { func Internal(cause error) *Error {
return &Error{Status: 500, Code: "server_error", Message: "internal server error", Cause: cause} return &Error{Status: 500, Code: "server_error", Message: "internal server error", Cause: cause}
} }
+4
View File
@@ -60,6 +60,10 @@ const (
// and its history moved onto the copy kept. The metadata names both, so the // and its history moved onto the copy kept. The metadata names both, so the
// log can answer "where did that file go" long after the report is gone. // log can answer "where did that file go" long after the report is gone.
ActionDuplicateMerge Action = "duplicate_merge" ActionDuplicateMerge Action = "duplicate_merge"
// Subsonic password (#5026): generated in Settings for t/s-only clients.
ActionSubsonicPasswordSet Action = "subsonic_password_set"
ActionSubsonicPasswordClear Action = "subsonic_password_clear"
) )
// Write inserts one audit_log row. metadata is marshaled as JSON; // Write inserts one audit_log row. metadata is marshaled as JSON;
+2
View File
@@ -169,6 +169,8 @@ func TestWrite_AllActionConstantsArePersisted(t *testing.T) {
audit.ActionForgotPasswordInit, audit.ActionForgotPasswordInit,
audit.ActionPasswordResetByEmail, audit.ActionPasswordResetByEmail,
audit.ActionDuplicateMerge, audit.ActionDuplicateMerge,
audit.ActionSubsonicPasswordSet,
audit.ActionSubsonicPasswordClear,
} }
for _, a := range actions { for _, a := range actions {
if err := audit.Write(context.Background(), pool, nilUUID, nilUUID, a, nil); err != nil { if err := audit.Write(context.Background(), pool, nilUUID, nilUUID, a, nil); err != nil {
+30
View File
@@ -65,6 +65,36 @@ func ClientIP(r *http.Request, trustedProxyHops int) string {
return remote return remote
} }
// IsHTTPS reports whether the client reached us over HTTPS, reading through
// trustedProxyHops reverse proxies by the same rule as ClientIP.
//
// TLS terminates at the operator's proxy (rule 94), so r.TLS is nil in every
// real deployment and only X-Forwarded-Proto can say what the client used.
// That header is believed only when hops >= 1: with no trusted proxy it is
// just something any client can send. Proxies that append rather than
// overwrite produce a comma list, read positionally like X-Forwarded-For.
//
// This decides only HTTPS-only behaviour (the cookie Secure flag, HSTS). It
// must never drive a redirect: plain-HTTP and LAN use keep working.
func IsHTTPS(r *http.Request, trustedProxyHops int) bool {
if r.TLS != nil {
return true
}
if trustedProxyHops <= 0 {
return false
}
raw := r.Header.Get("X-Forwarded-Proto")
if strings.TrimSpace(raw) == "" {
return false
}
parts := strings.Split(raw, ",")
idx := len(parts) - trustedProxyHops
if idx < 0 {
idx = 0
}
return strings.EqualFold(strings.TrimSpace(parts[idx]), "https")
}
// forwardedChain returns the X-Forwarded-For entries in wire order, or the // forwardedChain returns the X-Forwarded-For entries in wire order, or the
// single X-Real-IP value when XFF is absent. // single X-Real-IP value when XFF is absent.
// //
+43
View File
@@ -1,6 +1,7 @@
package auth package auth
import ( import (
"crypto/tls"
"net/http" "net/http"
"testing" "testing"
) )
@@ -168,3 +169,45 @@ func TestClientIP(t *testing.T) {
}) })
} }
} }
func TestIsHTTPS(t *testing.T) {
tests := []struct {
name string
hops int
proto string
tls bool
want bool
}{
// The three cases rule 94 names.
{name: "hops 0 ignores a forwarded https", hops: 0, proto: "https", want: false},
{name: "hops 1 believes a forwarded https", hops: 1, proto: "https", want: true},
{name: "plain request is not https", hops: 1, want: false},
{name: "forwarded http is not https", hops: 1, proto: "http", want: false},
{name: "case-insensitive", hops: 1, proto: "HTTPS", want: true},
{name: "direct TLS is https whatever the hops", hops: 0, tls: true, want: true},
// An appending chain is read positionally: with one trusted hop the
// last entry is our proxy's word, and a client-supplied first entry
// cannot flip it.
{name: "appended chain reads our proxy's entry", hops: 1, proto: "https, http", want: false},
{name: "two hops read the CDN's entry", hops: 2, proto: "https, http", want: true},
{name: "over-counted hops clamp to the first entry", hops: 5, proto: "https", want: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
r, err := http.NewRequest(http.MethodGet, "/api/auth/login", nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
if tc.proto != "" {
r.Header.Set("X-Forwarded-Proto", tc.proto)
}
if tc.tls {
r.TLS = &tls.ConnectionState{}
}
if got := IsHTTPS(r, tc.hops); got != tc.want {
t.Errorf("IsHTTPS(hops=%d, proto=%q) = %v, want %v", tc.hops, tc.proto, got, tc.want)
}
})
}
}
+205
View File
@@ -0,0 +1,205 @@
package auth
import (
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
// AttemptLimiter caps how many attempts a key may make inside a fixed
// window. It is the throttle in front of every password-shaped check:
// native login, register, forgot/reset password and Subsonic /rest auth.
//
// In memory on purpose. Minstrel is a single process, the counts only need
// to outlive a guessing run rather than a restart, and a table would put a
// write on every failed login. Each key costs one small struct, and expired
// keys are swept as the map grows, so a spray across many addresses cannot
// hold memory past one window.
type AttemptLimiter struct {
max int
window time.Duration
now func() time.Time
mu sync.Mutex
buckets map[string]*attemptBucket
nextSweep int
}
type attemptBucket struct {
count int
start time.Time
}
// sweepFloor is the map size below which expired keys are left in place;
// past it, a sweep runs whenever the map doubles from its last swept size.
const sweepFloor = 1024
// NewAttemptLimiter returns a limiter allowing max attempts per key per
// window.
func NewAttemptLimiter(max int, window time.Duration) *AttemptLimiter {
return &AttemptLimiter{
max: max,
window: window,
now: time.Now,
buckets: map[string]*attemptBucket{},
nextSweep: sweepFloor,
}
}
// Blocked reports whether key has used its attempts for the current window,
// and if so how long until the window resets. It records nothing, so a check
// can run before the expensive work and the outcome be recorded after.
func (l *AttemptLimiter) Blocked(key string) (bool, time.Duration) {
if l == nil || key == "" {
return false, 0
}
l.mu.Lock()
defer l.mu.Unlock()
b, ok := l.buckets[key]
if !ok {
return false, 0
}
now := l.now()
if now.Sub(b.start) >= l.window {
delete(l.buckets, key)
return false, 0
}
if b.count < l.max {
return false, 0
}
return true, b.start.Add(l.window).Sub(now)
}
// Record counts one attempt against key.
func (l *AttemptLimiter) Record(key string) {
if l == nil || key == "" {
return
}
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
b, ok := l.buckets[key]
if !ok || now.Sub(b.start) >= l.window {
l.buckets[key] = &attemptBucket{count: 1, start: now}
l.maybeSweep(now)
return
}
b.count++
}
// Reset forgets key, as after a successful login: the user who finally got
// their password right should not carry their typos into the next window.
func (l *AttemptLimiter) Reset(key string) {
if l == nil || key == "" {
return
}
l.mu.Lock()
defer l.mu.Unlock()
delete(l.buckets, key)
}
// maybeSweep drops expired buckets once the map has doubled since the last
// sweep. Callers hold l.mu.
func (l *AttemptLimiter) maybeSweep(now time.Time) {
if len(l.buckets) < l.nextSweep {
return
}
for k, b := range l.buckets {
if now.Sub(b.start) >= l.window {
delete(l.buckets, k)
}
}
l.nextSweep = max(sweepFloor, 2*len(l.buckets))
}
// LoginGuard pairs a per-account and a per-address limiter, the shape every
// password check uses. The account limit stops a slow guess at one user from
// many addresses; the address limit stops one address spraying many users.
// Only failures are recorded, so a user who signs in correctly is never
// counted at all.
type LoginGuard struct {
account *AttemptLimiter
address *AttemptLimiter
}
// Login limits: 10 failures per account and 50 per address per 15 minutes,
// the same numbers ThoughtSync settled on. Generous enough that a user
// fumbling a password manager never meets them, tight enough that an online
// guess against bcrypt gets ~1,000 tries a day per account.
const (
loginWindow = 15 * time.Minute
loginAccountMax = 10
loginAddressMax = 50
)
// NewLoginGuard returns a guard with the default login limits.
func NewLoginGuard() *LoginGuard {
return &LoginGuard{
account: NewAttemptLimiter(loginAccountMax, loginWindow),
address: NewAttemptLimiter(loginAddressMax, loginWindow),
}
}
// Blocked reports whether either the account or the address is over its
// limit, with the longer of the two waits. Check it BEFORE verifying the
// password, so a blocked guess costs no bcrypt.
func (g *LoginGuard) Blocked(account, address string) (bool, time.Duration) {
if g == nil {
return false, 0
}
aBlocked, aWait := g.account.Blocked(accountKey(account))
ipBlocked, ipWait := g.address.Blocked(address)
return aBlocked || ipBlocked, max(aWait, ipWait)
}
// Fail records a failed attempt against both the account and the address.
// An unknown username counts against its name all the same, so the limit
// gives away nothing about which accounts exist.
func (g *LoginGuard) Fail(account, address string) {
if g == nil {
return
}
g.account.Record(accountKey(account))
g.address.Record(address)
}
// Succeed clears the account's failures. The address keeps its count: one
// address that guessed fifty accounts and got one right is still spraying.
func (g *LoginGuard) Succeed(account string) {
if g == nil {
return
}
g.account.Reset(accountKey(account))
}
// accountKey folds case so "Admin" and "admin" share one budget. Usernames
// are compared exactly by the lookup, but the guesser shouldn't get a fresh
// allowance per capitalisation.
func accountKey(account string) string {
return strings.ToLower(strings.TrimSpace(account))
}
var (
dummyHashOnce sync.Once
dummyHash []byte
)
// DummyVerify spends the same bcrypt time a real password check would, for
// the path where the username doesn't exist. Without it, an unknown user
// answers in microseconds and a known one in ~50ms, and the uniform error
// message hides nothing.
func DummyVerify(plaintext string) {
dummyHashOnce.Do(func() {
// What the hash is of doesn't matter — no account carries it. Its
// cost does: DefaultCost, the same as every stored password.
h, err := bcrypt.GenerateFromPassword([]byte("minstrel-dummy-password"), bcrypt.DefaultCost)
if err == nil {
dummyHash = h
}
})
if dummyHash != nil {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(plaintext))
}
}
+114
View File
@@ -0,0 +1,114 @@
package auth
import (
"testing"
"time"
)
// fakeClock lets a test step through a window without sleeping.
type fakeClock struct{ t time.Time }
func (c *fakeClock) now() time.Time { return c.t }
func newTestLimiter(max int, window time.Duration) (*AttemptLimiter, *fakeClock) {
c := &fakeClock{t: time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)}
l := NewAttemptLimiter(max, window)
l.now = c.now
return l, c
}
func TestAttemptLimiter_BlocksAtMaxAndReportsWait(t *testing.T) {
l, c := newTestLimiter(3, 15*time.Minute)
for i := 0; i < 3; i++ {
if blocked, _ := l.Blocked("k"); blocked {
t.Fatalf("blocked after %d attempts, want allowed below max", i)
}
l.Record("k")
}
c.t = c.t.Add(5 * time.Minute)
blocked, wait := l.Blocked("k")
if !blocked {
t.Fatal("not blocked after max attempts")
}
if wait != 10*time.Minute {
t.Errorf("wait = %v, want the 10m left in the window", wait)
}
}
func TestAttemptLimiter_WindowExpiryClears(t *testing.T) {
l, c := newTestLimiter(1, time.Minute)
l.Record("k")
if blocked, _ := l.Blocked("k"); !blocked {
t.Fatal("want blocked inside the window")
}
c.t = c.t.Add(time.Minute)
if blocked, _ := l.Blocked("k"); blocked {
t.Fatal("still blocked once the window has passed")
}
}
func TestAttemptLimiter_KeysAreIndependentAndResetClears(t *testing.T) {
l, _ := newTestLimiter(1, time.Minute)
l.Record("a")
if blocked, _ := l.Blocked("b"); blocked {
t.Fatal("one key's attempts blocked another")
}
l.Reset("a")
if blocked, _ := l.Blocked("a"); blocked {
t.Fatal("Reset did not clear the key")
}
}
func TestAttemptLimiter_SweepDropsExpiredKeys(t *testing.T) {
l, c := newTestLimiter(5, time.Minute)
// One short of the floor: no sweep yet, however stale these become.
for i := 0; i < sweepFloor-1; i++ {
l.Record("k" + time.Duration(i).String())
}
c.t = c.t.Add(2 * time.Minute)
l.Record("fresh") // reaches the floor and triggers a sweep
if n := len(l.buckets); n != 1 {
t.Errorf("buckets after sweep = %d, want only the fresh key", n)
}
}
func TestAttemptLimiter_NilAndEmptyKeyAreNoOps(t *testing.T) {
var l *AttemptLimiter
l.Record("k")
if blocked, _ := l.Blocked("k"); blocked {
t.Error("nil limiter blocked")
}
real, _ := newTestLimiter(1, time.Minute)
real.Record("")
if blocked, _ := real.Blocked(""); blocked {
t.Error("empty key was counted")
}
}
func TestLoginGuard_AccountLimitSpansAddressesAndFoldsCase(t *testing.T) {
g := NewLoginGuard()
for i := 0; i < loginAccountMax; i++ {
g.Fail("Alice", "10.0.0."+string(rune('0'+i%10)))
}
if blocked, _ := g.Blocked("alice", "192.0.2.1"); !blocked {
t.Fatal("account limit should hold from a fresh address and any capitalisation")
}
g.Succeed("ALICE")
if blocked, _ := g.Blocked("alice", "192.0.2.1"); blocked {
t.Fatal("Succeed should clear the account's failures")
}
}
func TestLoginGuard_AddressLimitSpansAccounts(t *testing.T) {
g := NewLoginGuard()
for i := 0; i < loginAddressMax; i++ {
g.Fail("user"+time.Duration(i).String(), "203.0.113.9")
}
if blocked, _ := g.Blocked("someone-new", "203.0.113.9"); !blocked {
t.Fatal("address that sprayed many accounts should be blocked")
}
g.Succeed("someone-new")
if blocked, _ := g.Blocked("someone-new", "203.0.113.9"); !blocked {
t.Fatal("a success must not clear the address count")
}
}
+10
View File
@@ -5,6 +5,7 @@ import (
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/hex"
"errors" "errors"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -41,6 +42,15 @@ func HashSessionToken(token string) []byte {
return sum[:] return sum[:]
} }
// HashAPIToken maps a raw API key (the OpenSubsonic apiKey) to the
// `users.api_token_hash` column: sha256, hex. The key is minted with
// MintSessionToken, so it carries the same 256 bits and the same reasoning
// as HashSessionToken applies. Hex rather than bytes so the column stays
// text and a migration can compute it in SQL from the old plaintext.
func HashAPIToken(token string) string {
return hex.EncodeToString(HashSessionToken(token))
}
// VerifyPassword is the canonical bcrypt comparison. Returns false on a // VerifyPassword is the canonical bcrypt comparison. Returns false on a
// malformed hash so callers don't need to distinguish "hash invalid" from // malformed hash so callers don't need to distinguish "hash invalid" from
// "password wrong" — both are auth failures from the client's perspective. // "password wrong" — both are auth failures from the client's perspective.
+46
View File
@@ -0,0 +1,46 @@
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
)
// SetupToken guards the first-admin registration. Until the first account
// exists, register makes whoever calls it the admin, so a fresh instance on a
// public address belonged to whoever found it first. Now that call also has
// to carry this token, which is generated at startup and written only to the
// server log: proof that the caller can read the operator's logs.
//
// The token lives in memory. A restart mints a new one and logs it again,
// which is the behaviour wanted: an old token from a log line someone else
// saw stops working.
type SetupToken struct {
value string
}
// NewSetupToken mints a 128-bit token.
func NewSetupToken() (*SetupToken, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return nil, err
}
return &SetupToken{value: hex.EncodeToString(b)}, nil
}
// Value returns the token for logging.
func (t *SetupToken) Value() string {
if t == nil {
return ""
}
return t.value
}
// Matches reports whether supplied is the token, in constant time. A nil
// token never matches anything, so a missing token fails closed.
func (t *SetupToken) Matches(supplied string) bool {
if t == nil || t.value == "" || supplied == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(t.value), []byte(supplied)) == 1
}
+1 -1
View File
@@ -42,7 +42,7 @@ func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Disca
func seedUser(t *testing.T, q *dbq.Queries, name string) pgtype.UUID { func seedUser(t *testing.T, q *dbq.Queries, name string) pgtype.UUID {
t.Helper() t.Helper()
u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{ u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiToken: name + "-tok", IsAdmin: false, Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiTokenHash: name + "-tok", IsAdmin: false,
}) })
if err != nil { if err != nil {
t.Fatalf("seed user %s: %v", name, err) t.Fatalf("seed user %s: %v", name, err)
+16
View File
@@ -84,6 +84,22 @@ func (q *Queries) GcDeleteExpiredPasswordResets(ctx context.Context) (int64, err
return result.RowsAffected(), nil return result.RowsAffected(), nil
} }
const gcDeleteExpiredSessions = `-- name: GcDeleteExpiredSessions :execrows
DELETE FROM sessions
WHERE last_seen_at <= now() - interval '30 days'
OR created_at <= now() - interval '365 days'
`
// Sessions past their idle (30 days) or absolute (1 year) limit. They already
// fail auth through GetSessionByTokenHash's filter; this only clears the rows.
func (q *Queries) GcDeleteExpiredSessions(ctx context.Context) (int64, error) {
result, err := q.db.Exec(ctx, gcDeleteExpiredSessions)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const gcExpireScrobbleQueueFailedRows = `-- name: GcExpireScrobbleQueueFailedRows :execrows const gcExpireScrobbleQueueFailedRows = `-- name: GcExpireScrobbleQueueFailedRows :execrows
DELETE FROM scrobble_queue DELETE FROM scrobble_queue
WHERE status = 'failed' WHERE status = 'failed'
+498
View File
@@ -0,0 +1,498 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: loudness.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const deleteAlbumLoudness = `-- name: DeleteAlbumLoudness :exec
DELETE FROM album_loudness WHERE album_id = ANY($1::uuid[])
`
func (q *Queries) DeleteAlbumLoudness(ctx context.Context, albumIds []pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteAlbumLoudness, albumIds)
return err
}
const deleteTrackLoudness = `-- name: DeleteTrackLoudness :exec
DELETE FROM track_loudness WHERE track_id = $1
`
// The scan saw new bytes at this path. The stored measurement describes the old
// ones, so it goes, and the backfill measures the file again.
func (q *Queries) DeleteTrackLoudness(ctx context.Context, trackID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteTrackLoudness, trackID)
return err
}
const getAlbumLoudness = `-- name: GetAlbumLoudness :one
SELECT integrated_lufs, true_peak_dbtp FROM album_loudness WHERE album_id = $1
`
type GetAlbumLoudnessRow struct {
IntegratedLufs *float32
TruePeakDbtp *float32
}
// The stored album values, read before a recompute so a sync change is
// logged only when they actually move.
func (q *Queries) GetAlbumLoudness(ctx context.Context, albumID pgtype.UUID) (GetAlbumLoudnessRow, error) {
row := q.db.QueryRow(ctx, getAlbumLoudness, albumID)
var i GetAlbumLoudnessRow
err := row.Scan(&i.IntegratedLufs, &i.TruePeakDbtp)
return i, err
}
const getAlbumLoudnessByIDs = `-- name: GetAlbumLoudnessByIDs :many
SELECT album_id, integrated_lufs, true_peak_dbtp
FROM album_loudness
WHERE album_id = ANY($1::uuid[])
`
type GetAlbumLoudnessByIDsRow struct {
AlbumID pgtype.UUID
IntegratedLufs *float32
TruePeakDbtp *float32
}
func (q *Queries) GetAlbumLoudnessByIDs(ctx context.Context, ids []pgtype.UUID) ([]GetAlbumLoudnessByIDsRow, error) {
rows, err := q.db.Query(ctx, getAlbumLoudnessByIDs, ids)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAlbumLoudnessByIDsRow
for rows.Next() {
var i GetAlbumLoudnessByIDsRow
if err := rows.Scan(&i.AlbumID, &i.IntegratedLufs, &i.TruePeakDbtp); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getLoudnessCoverage = `-- name: GetLoudnessCoverage :one
SELECT count(*)::bigint AS total,
count(*) FILTER (
WHERE l.analysis_version >= $1
AND l.integrated_lufs IS NOT NULL
)::bigint AS measured,
count(*) FILTER (
WHERE l.analysis_version >= $1
AND l.integrated_lufs IS NULL AND NOT l.unreadable
)::bigint AS silent,
count(*) FILTER (
WHERE l.analysis_version >= $1
AND l.unreadable
)::bigint AS unreadable,
count(*) FILTER (
WHERE l.track_id IS NULL OR l.analysis_version < $1
)::bigint AS pending
FROM tracks t
LEFT JOIN track_loudness l ON l.track_id = t.id
WHERE t.missing_since IS NULL
`
type GetLoudnessCoverageRow struct {
Total int64
Measured int64
Silent int64
Unreadable int64
Pending int64
}
// The admin gauge. measured + silent + unreadable + pending = total. Missing
// tracks are excluded, or the gauge could never reach the end.
func (q *Queries) GetLoudnessCoverage(ctx context.Context, currentVersion int16) (GetLoudnessCoverageRow, error) {
row := q.db.QueryRow(ctx, getLoudnessCoverage, currentVersion)
var i GetLoudnessCoverageRow
err := row.Scan(
&i.Total,
&i.Measured,
&i.Silent,
&i.Unreadable,
&i.Pending,
)
return i, err
}
const getLoudnessSettings = `-- name: GetLoudnessSettings :one
SELECT id, enabled, backfill_concurrency, updated_at FROM loudness_settings WHERE id = true
`
func (q *Queries) GetLoudnessSettings(ctx context.Context) (LoudnessSetting, error) {
row := q.db.QueryRow(ctx, getLoudnessSettings)
var i LoudnessSetting
err := row.Scan(
&i.ID,
&i.Enabled,
&i.BackfillConcurrency,
&i.UpdatedAt,
)
return i, err
}
const getReplayGainByTrackIDs = `-- name: GetReplayGainByTrackIDs :many
SELECT t.id,
tl.integrated_lufs AS track_lufs,
tl.true_peak_dbtp AS track_peak_dbtp,
al.integrated_lufs AS album_lufs,
al.true_peak_dbtp AS album_peak_dbtp
FROM tracks t
LEFT JOIN track_loudness tl ON tl.track_id = t.id
LEFT JOIN album_loudness al ON al.album_id = t.album_id
WHERE t.id = ANY($1::uuid[])
`
type GetReplayGainByTrackIDsRow struct {
ID pgtype.UUID
TrackLufs *float32
TrackPeakDbtp *float32
AlbumLufs *float32
AlbumPeakDbtp *float32
}
// Track and album loudness for a set of tracks, for every surface that hands
// gains to a client (#4997). A measurement from an older analysis version is
// still delivered: it is a better gain than none until the backfill redoes it.
func (q *Queries) GetReplayGainByTrackIDs(ctx context.Context, ids []pgtype.UUID) ([]GetReplayGainByTrackIDsRow, error) {
rows, err := q.db.Query(ctx, getReplayGainByTrackIDs, ids)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetReplayGainByTrackIDsRow
for rows.Next() {
var i GetReplayGainByTrackIDsRow
if err := rows.Scan(
&i.ID,
&i.TrackLufs,
&i.TrackPeakDbtp,
&i.AlbumLufs,
&i.AlbumPeakDbtp,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAlbumLoudnessInputs = `-- name: ListAlbumLoudnessInputs :many
SELECT t.id,
(l.track_id IS NOT NULL)::boolean AS settled,
l.true_peak_dbtp,
l.block_hist_start,
l.block_hist
FROM tracks t
LEFT JOIN track_loudness l
ON l.track_id = t.id AND l.analysis_version >= $1
WHERE t.album_id = $2
AND t.missing_since IS NULL
`
type ListAlbumLoudnessInputsParams struct {
CurrentVersion int16
AlbumID pgtype.UUID
}
type ListAlbumLoudnessInputsRow struct {
ID pgtype.UUID
Settled bool
TruePeakDbtp *float32
BlockHistStart *int16
BlockHist []int32
}
// Every present track on one album with its current measurement, if any.
// settled is false for a track not yet measured at the current version.
func (q *Queries) ListAlbumLoudnessInputs(ctx context.Context, arg ListAlbumLoudnessInputsParams) ([]ListAlbumLoudnessInputsRow, error) {
rows, err := q.db.Query(ctx, listAlbumLoudnessInputs, arg.CurrentVersion, arg.AlbumID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListAlbumLoudnessInputsRow
for rows.Next() {
var i ListAlbumLoudnessInputsRow
if err := rows.Scan(
&i.ID,
&i.Settled,
&i.TruePeakDbtp,
&i.BlockHistStart,
&i.BlockHist,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAlbumsNeedingLoudness = `-- name: ListAlbumsNeedingLoudness :many
WITH present AS (
SELECT t.album_id,
md5(string_agg(
t.id::text || ':' || coalesce(
l.analysis_version::text || '@' || l.analyzed_at::text, '-'),
',' ORDER BY t.id)) AS digest
FROM tracks t
LEFT JOIN track_loudness l
ON l.track_id = t.id AND l.analysis_version >= $3::smallint
WHERE t.missing_since IS NULL
GROUP BY t.album_id
)
SELECT c.album_id, c.digest::text AS digest
FROM present c
LEFT JOIN album_loudness a ON a.album_id = c.album_id
WHERE (a.album_id IS NULL OR a.inputs_digest <> c.digest)
-- Casts: sqlc cannot infer a parameter's type through a CTE alias.
AND c.album_id > $1::uuid
ORDER BY c.album_id
LIMIT $2::integer
`
type ListAlbumsNeedingLoudnessParams struct {
AfterID pgtype.UUID
BatchLimit int32
CurrentVersion int16
}
type ListAlbumsNeedingLoudnessRow struct {
AlbumID pgtype.UUID
Digest string
}
// The album pass's work queue (#4996): albums whose present tracks or their
// measurements have changed since album loudness was last computed, or that
// never had it. The digest is over every present track's id and the
// measurement it holds at the current version ('-' for none), so a track
// joining, leaving or being re-measured changes it. Keyset-paged on album id
// so a pass ends even if storing one album keeps failing.
func (q *Queries) ListAlbumsNeedingLoudness(ctx context.Context, arg ListAlbumsNeedingLoudnessParams) ([]ListAlbumsNeedingLoudnessRow, error) {
rows, err := q.db.Query(ctx, listAlbumsNeedingLoudness, arg.AfterID, arg.BatchLimit, arg.CurrentVersion)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListAlbumsNeedingLoudnessRow
for rows.Next() {
var i ListAlbumsNeedingLoudnessRow
if err := rows.Scan(&i.AlbumID, &i.Digest); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listOrphanAlbumLoudness = `-- name: ListOrphanAlbumLoudness :many
SELECT a.album_id
FROM album_loudness a
WHERE NOT EXISTS (
SELECT 1 FROM tracks t WHERE t.album_id = a.album_id AND t.missing_since IS NULL
)
`
// Album rows whose album has no present track left (every file missing). The
// album row itself survives a missing file, so its loudness would otherwise
// stay behind describing tracks that are gone; it is recomputed if they return.
func (q *Queries) ListOrphanAlbumLoudness(ctx context.Context) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, listOrphanAlbumLoudness)
if err != nil {
return nil, err
}
defer rows.Close()
var items []pgtype.UUID
for rows.Next() {
var album_id pgtype.UUID
if err := rows.Scan(&album_id); err != nil {
return nil, err
}
items = append(items, album_id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTracksNeedingLoudness = `-- name: ListTracksNeedingLoudness :many
SELECT t.id, t.file_path, t.duration_ms
FROM tracks t
LEFT JOIN track_loudness l ON l.track_id = t.id
WHERE t.missing_since IS NULL
AND (l.track_id IS NULL OR l.analysis_version < $1)
AND t.id > $2
ORDER BY t.id
LIMIT $3
`
type ListTracksNeedingLoudnessParams struct {
CurrentVersion int16
AfterID pgtype.UUID
BatchLimit int32
}
type ListTracksNeedingLoudnessRow struct {
ID pgtype.UUID
FilePath string
DurationMs int32
}
// The backfill's work queue: tracks with no measurement, or one taken by an
// older method. Keyset-paged on id so a pass visits each track at most once;
// an inconclusive attempt writes no row, and without the cursor a file that
// keeps timing out would be listed again straight away. Missing tracks are
// skipped: there is no file to read.
func (q *Queries) ListTracksNeedingLoudness(ctx context.Context, arg ListTracksNeedingLoudnessParams) ([]ListTracksNeedingLoudnessRow, error) {
rows, err := q.db.Query(ctx, listTracksNeedingLoudness, arg.CurrentVersion, arg.AfterID, arg.BatchLimit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTracksNeedingLoudnessRow
for rows.Next() {
var i ListTracksNeedingLoudnessRow
if err := rows.Scan(&i.ID, &i.FilePath, &i.DurationMs); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateLoudnessSettings = `-- name: UpdateLoudnessSettings :one
UPDATE loudness_settings
SET enabled = $1,
backfill_concurrency = $2,
updated_at = now()
WHERE id = true
RETURNING id, enabled, backfill_concurrency, updated_at
`
type UpdateLoudnessSettingsParams struct {
Enabled bool
BackfillConcurrency int32
}
// Whole-row write from the admin card; migration 0065's CHECK is the backstop
// behind the service's own validation.
func (q *Queries) UpdateLoudnessSettings(ctx context.Context, arg UpdateLoudnessSettingsParams) (LoudnessSetting, error) {
row := q.db.QueryRow(ctx, updateLoudnessSettings, arg.Enabled, arg.BackfillConcurrency)
var i LoudnessSetting
err := row.Scan(
&i.ID,
&i.Enabled,
&i.BackfillConcurrency,
&i.UpdatedAt,
)
return i, err
}
const upsertAlbumLoudness = `-- name: UpsertAlbumLoudness :exec
INSERT INTO album_loudness (
album_id, integrated_lufs, true_peak_dbtp, tracks_total, tracks_settled, inputs_digest
) VALUES (
$1, $2, $3,
$4, $5, $6
)
ON CONFLICT (album_id) DO UPDATE SET
integrated_lufs = EXCLUDED.integrated_lufs,
true_peak_dbtp = EXCLUDED.true_peak_dbtp,
tracks_total = EXCLUDED.tracks_total,
tracks_settled = EXCLUDED.tracks_settled,
inputs_digest = EXCLUDED.inputs_digest,
computed_at = now()
`
type UpsertAlbumLoudnessParams struct {
AlbumID pgtype.UUID
IntegratedLufs *float32
TruePeakDbtp *float32
TracksTotal int32
TracksSettled int32
InputsDigest string
}
func (q *Queries) UpsertAlbumLoudness(ctx context.Context, arg UpsertAlbumLoudnessParams) error {
_, err := q.db.Exec(ctx, upsertAlbumLoudness,
arg.AlbumID,
arg.IntegratedLufs,
arg.TruePeakDbtp,
arg.TracksTotal,
arg.TracksSettled,
arg.InputsDigest,
)
return err
}
const upsertTrackLoudness = `-- name: UpsertTrackLoudness :exec
INSERT INTO track_loudness (
track_id, integrated_lufs, true_peak_dbtp, loudness_range_lu,
block_hist_start, block_hist, unreadable, analysis_version
) VALUES (
$1, $2, $3,
$4, $5, $6,
$7, $8
)
ON CONFLICT (track_id) DO UPDATE SET
integrated_lufs = EXCLUDED.integrated_lufs,
true_peak_dbtp = EXCLUDED.true_peak_dbtp,
loudness_range_lu = EXCLUDED.loudness_range_lu,
block_hist_start = EXCLUDED.block_hist_start,
block_hist = EXCLUDED.block_hist,
unreadable = EXCLUDED.unreadable,
analysis_version = EXCLUDED.analysis_version,
analyzed_at = now()
`
type UpsertTrackLoudnessParams struct {
TrackID pgtype.UUID
IntegratedLufs *float32
TruePeakDbtp *float32
LoudnessRangeLu *float32
BlockHistStart *int16
BlockHist []int32
Unreadable bool
AnalysisVersion int16
}
// Written when the backfill measures a track (#4995). Replaces the row
// wholesale: a measurement of the old bytes has no standing once the file has
// changed.
func (q *Queries) UpsertTrackLoudness(ctx context.Context, arg UpsertTrackLoudnessParams) error {
_, err := q.db.Exec(ctx, upsertTrackLoudness,
arg.TrackID,
arg.IntegratedLufs,
arg.TruePeakDbtp,
arg.LoudnessRangeLu,
arg.BlockHistStart,
arg.BlockHist,
arg.Unreadable,
arg.AnalysisVersion,
)
return err
}
+31 -1
View File
@@ -201,6 +201,16 @@ type Album struct {
CoverArtSourcesVersion int32 CoverArtSourcesVersion int32
} }
type AlbumLoudness struct {
AlbumID pgtype.UUID
IntegratedLufs *float32
TruePeakDbtp *float32
TracksTotal int32
TracksSettled int32
InputsDigest string
ComputedAt pgtype.Timestamptz
}
type Artist struct { type Artist struct {
ID pgtype.UUID ID pgtype.UUID
Name string Name string
@@ -417,6 +427,13 @@ type LidarrRequest struct {
LidarrAddConfirmedAt pgtype.Timestamptz LidarrAddConfirmedAt pgtype.Timestamptz
} }
type LoudnessSetting struct {
ID bool
Enabled bool
BackfillConcurrency int32
UpdatedAt pgtype.Timestamptz
}
type MissingReacquisition struct { type MissingReacquisition struct {
AlbumID pgtype.UUID AlbumID pgtype.UUID
Attempts int32 Attempts int32
@@ -430,6 +447,7 @@ type MissingReacquisition struct {
type NetworkSetting struct { type NetworkSetting struct {
ID bool ID bool
TrustedProxyHops int32 TrustedProxyHops int32
PublicUrl string
} }
type PasswordReset struct { type PasswordReset struct {
@@ -712,6 +730,18 @@ type TrackFingerprint struct {
ChromaprintLengthSec int32 ChromaprintLengthSec int32
} }
type TrackLoudness struct {
TrackID pgtype.UUID
IntegratedLufs *float32
TruePeakDbtp *float32
LoudnessRangeLu *float32
BlockHistStart *int16
BlockHist []int32
Unreadable bool
AnalysisVersion int16
AnalyzedAt pgtype.Timestamptz
}
type TrackSimilarity struct { type TrackSimilarity struct {
TrackAID pgtype.UUID TrackAID pgtype.UUID
TrackBID pgtype.UUID TrackBID pgtype.UUID
@@ -730,7 +760,6 @@ type User struct {
ID pgtype.UUID ID pgtype.UUID
Username string Username string
PasswordHash string PasswordHash string
ApiToken string
IsAdmin bool IsAdmin bool
CreatedAt pgtype.Timestamptz CreatedAt pgtype.Timestamptz
SubsonicPassword *string SubsonicPassword *string
@@ -742,6 +771,7 @@ type User struct {
Timezone string Timezone string
TimezoneUpdatedAt pgtype.Timestamptz TimezoneUpdatedAt pgtype.Timestamptz
DebugModeEnabled bool DebugModeEnabled bool
ApiTokenHash string
} }
type UserInvite struct { type UserInvite struct {
+15 -4
View File
@@ -10,23 +10,34 @@ import (
) )
const getNetworkSettings = `-- name: GetNetworkSettings :one const getNetworkSettings = `-- name: GetNetworkSettings :one
SELECT id, trusted_proxy_hops FROM network_settings WHERE id = true SELECT id, trusted_proxy_hops, public_url FROM network_settings WHERE id = true
` `
func (q *Queries) GetNetworkSettings(ctx context.Context) (NetworkSetting, error) { func (q *Queries) GetNetworkSettings(ctx context.Context) (NetworkSetting, error) {
row := q.db.QueryRow(ctx, getNetworkSettings) row := q.db.QueryRow(ctx, getNetworkSettings)
var i NetworkSetting var i NetworkSetting
err := row.Scan(&i.ID, &i.TrustedProxyHops) err := row.Scan(&i.ID, &i.TrustedProxyHops, &i.PublicUrl)
return i, err
}
const updatePublicURL = `-- name: UpdatePublicURL :one
UPDATE network_settings SET public_url = $1 WHERE id = true RETURNING id, trusted_proxy_hops, public_url
`
func (q *Queries) UpdatePublicURL(ctx context.Context, publicUrl string) (NetworkSetting, error) {
row := q.db.QueryRow(ctx, updatePublicURL, publicUrl)
var i NetworkSetting
err := row.Scan(&i.ID, &i.TrustedProxyHops, &i.PublicUrl)
return i, err return i, err
} }
const updateTrustedProxyHops = `-- name: UpdateTrustedProxyHops :one const updateTrustedProxyHops = `-- name: UpdateTrustedProxyHops :one
UPDATE network_settings SET trusted_proxy_hops = $1 WHERE id = true RETURNING id, trusted_proxy_hops UPDATE network_settings SET trusted_proxy_hops = $1 WHERE id = true RETURNING id, trusted_proxy_hops, public_url
` `
func (q *Queries) UpdateTrustedProxyHops(ctx context.Context, trustedProxyHops int32) (NetworkSetting, error) { func (q *Queries) UpdateTrustedProxyHops(ctx context.Context, trustedProxyHops int32) (NetworkSetting, error) {
row := q.db.QueryRow(ctx, updateTrustedProxyHops, trustedProxyHops) row := q.db.QueryRow(ctx, updateTrustedProxyHops, trustedProxyHops)
var i NetworkSetting var i NetworkSetting
err := row.Scan(&i.ID, &i.TrustedProxyHops) err := row.Scan(&i.ID, &i.TrustedProxyHops, &i.PublicUrl)
return i, err return i, err
} }
+37 -3
View File
@@ -69,10 +69,38 @@ func (q *Queries) DeleteSessionForUser(ctx context.Context, arg DeleteSessionFor
return result.RowsAffected(), nil return result.RowsAffected(), nil
} }
const getSessionByTokenHash = `-- name: GetSessionByTokenHash :one const deleteSessionsForUser = `-- name: DeleteSessionsForUser :execrows
SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip FROM sessions WHERE token_hash = $1 DELETE FROM sessions WHERE user_id = $1
` `
// Every session the user has, the caller's included. A password reset uses
// it: whoever forgot the password is not signed in anywhere they trust, and
// whoever may have learned it must be signed out everywhere.
func (q *Queries) DeleteSessionsForUser(ctx context.Context, userID pgtype.UUID) (int64, error) {
result, err := q.db.Exec(ctx, deleteSessionsForUser, userID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const getSessionByTokenHash = `-- name: GetSessionByTokenHash :one
SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip FROM sessions
WHERE token_hash = $1
AND last_seen_at > now() - interval '30 days'
AND created_at > now() - interval '365 days'
`
// Expired sessions are invisible here, so they fail auth the moment they
// lapse rather than whenever the GC sweep next runs. Two limits:
//
// idle 30 days — a token nobody has used in a month is abandoned, and
// matches the web cookie's lifetime;
// absolute 1 year — even a token in daily use is re-issued yearly, so a
// stolen one that is being used quietly does not live
// forever.
//
// Keep in step with ListSessionsForUser and GcDeleteExpiredSessions.
func (q *Queries) GetSessionByTokenHash(ctx context.Context, tokenHash []byte) (Session, error) { func (q *Queries) GetSessionByTokenHash(ctx context.Context, tokenHash []byte) (Session, error) {
row := q.db.QueryRow(ctx, getSessionByTokenHash, tokenHash) row := q.db.QueryRow(ctx, getSessionByTokenHash, tokenHash)
var i Session var i Session
@@ -127,11 +155,17 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (S
} }
const listSessionsForUser = `-- name: ListSessionsForUser :many const listSessionsForUser = `-- name: ListSessionsForUser :many
SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip FROM sessions WHERE user_id = $1 ORDER BY last_seen_at DESC SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip FROM sessions
WHERE user_id = $1
AND last_seen_at > now() - interval '30 days'
AND created_at > now() - interval '365 days'
ORDER BY last_seen_at DESC
` `
// Most-recently-active first: the row a user is most likely to act on is the // Most-recently-active first: the row a user is most likely to act on is the
// one that moved last, and an unfamiliar entry at the top is the alarm. // one that moved last, and an unfamiliar entry at the top is the alarm.
// Expired rows are left out: they no longer authenticate, so listing them
// as active would be wrong. Same limits as GetSessionByTokenHash.
func (q *Queries) ListSessionsForUser(ctx context.Context, userID pgtype.UUID) ([]Session, error) { func (q *Queries) ListSessionsForUser(ctx context.Context, userID pgtype.UUID) ([]Session, error) {
rows, err := q.db.Query(ctx, listSessionsForUser, userID) rows, err := q.db.Query(ctx, listSessionsForUser, userID)
if err != nil { if err != nil {
+47 -64
View File
@@ -52,15 +52,15 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) {
} }
const createUser = `-- name: CreateUser :one const createUser = `-- name: CreateUser :one
INSERT INTO users (username, password_hash, api_token, is_admin, display_name) INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash
` `
type CreateUserParams struct { type CreateUserParams struct {
Username string Username string
PasswordHash string PasswordHash string
ApiToken string ApiTokenHash string
IsAdmin bool IsAdmin bool
DisplayName *string DisplayName *string
} }
@@ -69,7 +69,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
row := q.db.QueryRow(ctx, createUser, row := q.db.QueryRow(ctx, createUser,
arg.Username, arg.Username,
arg.PasswordHash, arg.PasswordHash,
arg.ApiToken, arg.ApiTokenHash,
arg.IsAdmin, arg.IsAdmin,
arg.DisplayName, arg.DisplayName,
) )
@@ -78,7 +78,6 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.ID, &i.ID,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.ApiToken,
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.SubsonicPassword, &i.SubsonicPassword,
@@ -90,20 +89,21 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.Timezone, &i.Timezone,
&i.TimezoneUpdatedAt, &i.TimezoneUpdatedAt,
&i.DebugModeEnabled, &i.DebugModeEnabled,
&i.ApiTokenHash,
) )
return i, err return i, err
} }
const createUserAdmin = `-- name: CreateUserAdmin :one const createUserAdmin = `-- name: CreateUserAdmin :one
INSERT INTO users (username, password_hash, api_token, is_admin, display_name) INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash
` `
type CreateUserAdminParams struct { type CreateUserAdminParams struct {
Username string Username string
PasswordHash string PasswordHash string
ApiToken string ApiTokenHash string
IsAdmin bool IsAdmin bool
DisplayName *string DisplayName *string
} }
@@ -115,7 +115,7 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams
row := q.db.QueryRow(ctx, createUserAdmin, row := q.db.QueryRow(ctx, createUserAdmin,
arg.Username, arg.Username,
arg.PasswordHash, arg.PasswordHash,
arg.ApiToken, arg.ApiTokenHash,
arg.IsAdmin, arg.IsAdmin,
arg.DisplayName, arg.DisplayName,
) )
@@ -124,7 +124,6 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams
&i.ID, &i.ID,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.ApiToken,
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.SubsonicPassword, &i.SubsonicPassword,
@@ -136,24 +135,25 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams
&i.Timezone, &i.Timezone,
&i.TimezoneUpdatedAt, &i.TimezoneUpdatedAt,
&i.DebugModeEnabled, &i.DebugModeEnabled,
&i.ApiTokenHash,
) )
return i, err return i, err
} }
const createUserFirstAdminRace = `-- name: CreateUserFirstAdminRace :one const createUserFirstAdminRace = `-- name: CreateUserFirstAdminRace :one
INSERT INTO users (username, password_hash, api_token, is_admin, display_name) INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name)
VALUES ( VALUES (
$1, $2, $3, $1, $2, $3,
(SELECT NOT EXISTS (SELECT 1 FROM users)), (SELECT NOT EXISTS (SELECT 1 FROM users)),
$4 $4
) )
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash
` `
type CreateUserFirstAdminRaceParams struct { type CreateUserFirstAdminRaceParams struct {
Username string Username string
PasswordHash string PasswordHash string
ApiToken string ApiTokenHash string
DisplayName *string DisplayName *string
} }
@@ -174,7 +174,7 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
row := q.db.QueryRow(ctx, createUserFirstAdminRace, row := q.db.QueryRow(ctx, createUserFirstAdminRace,
arg.Username, arg.Username,
arg.PasswordHash, arg.PasswordHash,
arg.ApiToken, arg.ApiTokenHash,
arg.DisplayName, arg.DisplayName,
) )
var i User var i User
@@ -182,7 +182,6 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
&i.ID, &i.ID,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.ApiToken,
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.SubsonicPassword, &i.SubsonicPassword,
@@ -194,6 +193,7 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
&i.Timezone, &i.Timezone,
&i.TimezoneUpdatedAt, &i.TimezoneUpdatedAt,
&i.DebugModeEnabled, &i.DebugModeEnabled,
&i.ApiTokenHash,
) )
return i, err return i, err
} }
@@ -240,7 +240,7 @@ func (q *Queries) GetListenBrainzConfig(ctx context.Context, id pgtype.UUID) (Ge
} }
const getOldestAdmin = `-- name: GetOldestAdmin :one const getOldestAdmin = `-- name: GetOldestAdmin :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users
WHERE is_admin = true WHERE is_admin = true
ORDER BY created_at, id ORDER BY created_at, id
LIMIT 1 LIMIT 1
@@ -260,7 +260,6 @@ func (q *Queries) GetOldestAdmin(ctx context.Context) (User, error) {
&i.ID, &i.ID,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.ApiToken,
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.SubsonicPassword, &i.SubsonicPassword,
@@ -272,22 +271,22 @@ func (q *Queries) GetOldestAdmin(ctx context.Context) (User, error) {
&i.Timezone, &i.Timezone,
&i.TimezoneUpdatedAt, &i.TimezoneUpdatedAt,
&i.DebugModeEnabled, &i.DebugModeEnabled,
&i.ApiTokenHash,
) )
return i, err return i, err
} }
const getUserByAPIToken = `-- name: GetUserByAPIToken :one const getUserByAPITokenHash = `-- name: GetUserByAPITokenHash :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE api_token = $1 SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users WHERE api_token_hash = $1
` `
func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, error) { func (q *Queries) GetUserByAPITokenHash(ctx context.Context, apiTokenHash string) (User, error) {
row := q.db.QueryRow(ctx, getUserByAPIToken, apiToken) row := q.db.QueryRow(ctx, getUserByAPITokenHash, apiTokenHash)
var i User var i User
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.ApiToken,
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.SubsonicPassword, &i.SubsonicPassword,
@@ -299,12 +298,13 @@ func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User,
&i.Timezone, &i.Timezone,
&i.TimezoneUpdatedAt, &i.TimezoneUpdatedAt,
&i.DebugModeEnabled, &i.DebugModeEnabled,
&i.ApiTokenHash,
) )
return i, err return i, err
} }
const getUserByEmail = `-- name: GetUserByEmail :one const getUserByEmail = `-- name: GetUserByEmail :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE lower(email) = lower($1) SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users WHERE lower(email) = lower($1)
` `
// Used by forgot-password lookup. Lowercase comparison both sides // Used by forgot-password lookup. Lowercase comparison both sides
@@ -317,7 +317,6 @@ func (q *Queries) GetUserByEmail(ctx context.Context, lower string) (User, error
&i.ID, &i.ID,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.ApiToken,
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.SubsonicPassword, &i.SubsonicPassword,
@@ -329,12 +328,13 @@ func (q *Queries) GetUserByEmail(ctx context.Context, lower string) (User, error
&i.Timezone, &i.Timezone,
&i.TimezoneUpdatedAt, &i.TimezoneUpdatedAt,
&i.DebugModeEnabled, &i.DebugModeEnabled,
&i.ApiTokenHash,
) )
return i, err return i, err
} }
const getUserByID = `-- name: GetUserByID :one const getUserByID = `-- name: GetUserByID :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE id = $1 SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users WHERE id = $1
` `
func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) { func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) {
@@ -344,7 +344,6 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error)
&i.ID, &i.ID,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.ApiToken,
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.SubsonicPassword, &i.SubsonicPassword,
@@ -356,12 +355,13 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error)
&i.Timezone, &i.Timezone,
&i.TimezoneUpdatedAt, &i.TimezoneUpdatedAt,
&i.DebugModeEnabled, &i.DebugModeEnabled,
&i.ApiTokenHash,
) )
return i, err return i, err
} }
const getUserByUsername = `-- name: GetUserByUsername :one const getUserByUsername = `-- name: GetUserByUsername :one
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE username = $1 SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users WHERE username = $1
` `
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) { func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
@@ -371,7 +371,6 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
&i.ID, &i.ID,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.ApiToken,
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.SubsonicPassword, &i.SubsonicPassword,
@@ -383,6 +382,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
&i.Timezone, &i.Timezone,
&i.TimezoneUpdatedAt, &i.TimezoneUpdatedAt,
&i.DebugModeEnabled, &i.DebugModeEnabled,
&i.ApiTokenHash,
) )
return i, err return i, err
} }
@@ -471,39 +471,21 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
return items, nil return items, nil
} }
const regenerateApiToken = `-- name: RegenerateApiToken :one const regenerateApiToken = `-- name: RegenerateApiToken :exec
UPDATE users SET api_token = $2 WHERE id = $1 UPDATE users SET api_token_hash = $2 WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
` `
type RegenerateApiTokenParams struct { type RegenerateApiTokenParams struct {
ID pgtype.UUID ID pgtype.UUID
ApiToken string ApiTokenHash string
} }
// Self-service: caller wants a new API token. Used by the /settings // Self-service: caller wants a new API token. Used by the /settings
// API Token card's "Regenerate" button. // API Token card's "Regenerate" button. Only the hash is stored; the
func (q *Queries) RegenerateApiToken(ctx context.Context, arg RegenerateApiTokenParams) (User, error) { // handler returns the raw key once.
row := q.db.QueryRow(ctx, regenerateApiToken, arg.ID, arg.ApiToken) func (q *Queries) RegenerateApiToken(ctx context.Context, arg RegenerateApiTokenParams) error {
var i User _, err := q.db.Exec(ctx, regenerateApiToken, arg.ID, arg.ApiTokenHash)
err := row.Scan( return err
&i.ID,
&i.Username,
&i.PasswordHash,
&i.ApiToken,
&i.IsAdmin,
&i.CreatedAt,
&i.SubsonicPassword,
&i.ListenbrainzToken,
&i.ListenbrainzEnabled,
&i.DisplayName,
&i.AutoApproveRequests,
&i.Email,
&i.Timezone,
&i.TimezoneUpdatedAt,
&i.DebugModeEnabled,
)
return i, err
} }
const resetUserPassword = `-- name: ResetUserPassword :exec const resetUserPassword = `-- name: ResetUserPassword :exec
@@ -530,7 +512,7 @@ const setDebugMode = `-- name: SetDebugMode :one
UPDATE users UPDATE users
SET debug_mode_enabled = $2 SET debug_mode_enabled = $2
WHERE id = $1 WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash
` `
type SetDebugModeParams struct { type SetDebugModeParams struct {
@@ -548,7 +530,6 @@ func (q *Queries) SetDebugMode(ctx context.Context, arg SetDebugModeParams) (Use
&i.ID, &i.ID,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.ApiToken,
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.SubsonicPassword, &i.SubsonicPassword,
@@ -560,6 +541,7 @@ func (q *Queries) SetDebugMode(ctx context.Context, arg SetDebugModeParams) (Use
&i.Timezone, &i.Timezone,
&i.TimezoneUpdatedAt, &i.TimezoneUpdatedAt,
&i.DebugModeEnabled, &i.DebugModeEnabled,
&i.ApiTokenHash,
) )
return i, err return i, err
} }
@@ -607,7 +589,8 @@ type SetSubsonicPasswordParams struct {
} }
// Stores (or clears with NULL) the per-user Subsonic legacy credential used // Stores (or clears with NULL) the per-user Subsonic legacy credential used
// for t/s and p auth on /rest/*. Must be plaintext; see migration 0003. // for t/s and p auth on /rest/*. Must be plaintext; see migration 0003. Only
// ever a server-generated value, never the login password (#5026).
func (q *Queries) SetSubsonicPassword(ctx context.Context, arg SetSubsonicPasswordParams) error { func (q *Queries) SetSubsonicPassword(ctx context.Context, arg SetSubsonicPasswordParams) error {
_, err := q.db.Exec(ctx, setSubsonicPassword, arg.ID, arg.SubsonicPassword) _, err := q.db.Exec(ctx, setSubsonicPassword, arg.ID, arg.SubsonicPassword)
return err return err
@@ -617,7 +600,7 @@ const updateUserAdmin = `-- name: UpdateUserAdmin :one
UPDATE users UPDATE users
SET is_admin = $2 SET is_admin = $2
WHERE id = $1 WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash
` `
type UpdateUserAdminParams struct { type UpdateUserAdminParams struct {
@@ -634,7 +617,6 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams
&i.ID, &i.ID,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.ApiToken,
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.SubsonicPassword, &i.SubsonicPassword,
@@ -646,6 +628,7 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams
&i.Timezone, &i.Timezone,
&i.TimezoneUpdatedAt, &i.TimezoneUpdatedAt,
&i.DebugModeEnabled, &i.DebugModeEnabled,
&i.ApiTokenHash,
) )
return i, err return i, err
} }
@@ -654,7 +637,7 @@ const updateUserAutoApprove = `-- name: UpdateUserAutoApprove :one
UPDATE users UPDATE users
SET auto_approve_requests = $2 SET auto_approve_requests = $2
WHERE id = $1 WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash
` `
type UpdateUserAutoApproveParams struct { type UpdateUserAutoApproveParams struct {
@@ -670,7 +653,6 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA
&i.ID, &i.ID,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.ApiToken,
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.SubsonicPassword, &i.SubsonicPassword,
@@ -682,6 +664,7 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA
&i.Timezone, &i.Timezone,
&i.TimezoneUpdatedAt, &i.TimezoneUpdatedAt,
&i.DebugModeEnabled, &i.DebugModeEnabled,
&i.ApiTokenHash,
) )
return i, err return i, err
} }
@@ -691,7 +674,7 @@ UPDATE users
SET display_name = $2, SET display_name = $2,
email = $3 email = $3
WHERE id = $1 WHERE id = $1
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled RETURNING id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash
` `
type UpdateUserProfileParams struct { type UpdateUserProfileParams struct {
@@ -709,7 +692,6 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
&i.ID, &i.ID,
&i.Username, &i.Username,
&i.PasswordHash, &i.PasswordHash,
&i.ApiToken,
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.SubsonicPassword, &i.SubsonicPassword,
@@ -721,6 +703,7 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
&i.Timezone, &i.Timezone,
&i.TimezoneUpdatedAt, &i.TimezoneUpdatedAt,
&i.DebugModeEnabled, &i.DebugModeEnabled,
&i.ApiTokenHash,
) )
return i, err return i, err
} }
@@ -0,0 +1 @@
ALTER TABLE network_settings DROP COLUMN IF EXISTS public_url;
@@ -0,0 +1,8 @@
-- The address users reach Minstrel at, e.g. https://music.example.com.
--
-- Password-reset links used to be built from the request's Host header,
-- which the requester controls: a forgot-password call with a forged Host
-- would email the victim a real reset token on a link to the attacker's
-- server. Links are now built only from this operator-set value, and none
-- are sent while it is empty (M462 #4981).
ALTER TABLE network_settings ADD COLUMN public_url text NOT NULL DEFAULT '';
@@ -0,0 +1,7 @@
-- The keys cannot be recovered from their hashes. Rolling back gives every
-- user a new random key; Subsonic clients using apiKey need the new one.
ALTER TABLE users ADD COLUMN api_token text;
UPDATE users SET api_token = md5(random()::text || id::text || clock_timestamp()::text);
ALTER TABLE users ALTER COLUMN api_token SET NOT NULL;
ALTER TABLE users ADD CONSTRAINT users_api_token_key UNIQUE (api_token);
ALTER TABLE users DROP COLUMN api_token_hash;
@@ -0,0 +1,10 @@
-- API keys (the OpenSubsonic apiKey) are stored as their sha256, hex, the
-- same way session tokens are. A leaked database row or backup no longer
-- hands out working keys. Existing keys are hashed in place, so every
-- Subsonic client keeps working; the key itself can no longer be shown
-- again, only replaced (M462 #4983).
ALTER TABLE users ADD COLUMN api_token_hash text;
UPDATE users SET api_token_hash = encode(sha256(convert_to(api_token, 'UTF8')), 'hex');
ALTER TABLE users ALTER COLUMN api_token_hash SET NOT NULL;
ALTER TABLE users ADD CONSTRAINT users_api_token_hash_key UNIQUE (api_token_hash);
ALTER TABLE users DROP COLUMN api_token;
@@ -0,0 +1,2 @@
-- The cleared values are gone and were never meant to be kept; nothing to undo.
SELECT 1;
@@ -0,0 +1,10 @@
-- `minstrel admin reset-password` used to copy the new login password into
-- subsonic_password, so every account recovered through the CLI had its login
-- password stored in plain text, and a later password change in Settings left
-- that copy behind (M462 #5026). The CLI no longer writes this column; a
-- Subsonic password is now generated separately in Settings and is never the
-- login password. Clearing every value here removes the copies already made.
--
-- Accounts whose Subsonic client signs in with t/s stop working until the user
-- generates a Subsonic password (or switches the client to an API key).
UPDATE users SET subsonic_password = NULL WHERE subsonic_password IS NOT NULL;
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS loudness_settings;
DROP TABLE IF EXISTS track_loudness;
@@ -0,0 +1,65 @@
-- 0065_track_loudness.up.sql — measured loudness per track, for loudness
-- normalization (Scribe milestone #464, #4995).
--
-- Measured with ffmpeg's EBU R128 filter rather than read from ReplayGain tags:
-- tags in the wild are written against four different reference levels, and
-- most files have none. internal/library/loudness.go says how it is measured.
--
-- A table of its own rather than columns on tracks, for the reason
-- track_fingerprints is (0058): tracks is read with SELECT * on the hot path,
-- and the block histogram is a few hundred integers only album loudness reads.
--
-- What a row means, which the backfill depends on:
-- no row never analyzed, or the file changed since
-- analysis_version < current measured by an older method; measure again
-- analysis_version = current settled until the file changes:
-- integrated_lufs NOT NULL measured
-- integrated_lufs NULL, unreadable false
-- read fine, but no 400 ms block was above
-- the -70 LUFS gate: silence, or too short
-- unreadable true ffmpeg could not decode the file
-- A failure that says nothing about the file (a timeout, a cancelled pass, a
-- missing ffmpeg) writes no row, so the backfill tries again.
CREATE TABLE track_loudness (
track_id uuid PRIMARY KEY REFERENCES tracks (id) ON DELETE CASCADE,
-- Gated integrated loudness (ITU-R BS.1770), mono measured as dual mono.
integrated_lufs real,
-- Highest inter-sample peak, from 4x oversampling, in dB relative to full
-- scale. NULL for digital silence, whose peak is -inf.
true_peak_dbtp real,
-- Loudness range (EBU Tech 3342): how much the loudness moves within the
-- track. Not used for gain; kept because it costs nothing here.
loudness_range_lu real,
-- How many 400 ms gating blocks fell in each 0.1 LU bin. Bin i holds blocks
-- measuring -70.0 + (block_hist_start + i) / 10 LUFS; the array is trimmed
-- to the first and last non-empty bins. Album loudness is the gated loudness
-- of every block on the album, so it is computed from these exactly, with no
-- second decode (#4996).
block_hist_start smallint,
block_hist integer[],
unreadable boolean NOT NULL DEFAULT false,
analysis_version smallint NOT NULL,
analyzed_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT track_loudness_hist_pair
CHECK ((block_hist IS NULL) = (block_hist_start IS NULL))
);
-- Loudness analysis's knobs, in admin Settings. Rule 25: an operator setting is
-- a database row, changed without a restart. Singleton in the style of
-- fingerprint_settings (0061).
CREATE TABLE loudness_settings (
id boolean PRIMARY KEY DEFAULT true,
-- Off stops the background analysis. Tracks already measured keep their
-- values, so normalization keeps working for them.
enabled boolean NOT NULL DEFAULT true,
-- Files analyzed at once. Each is a full decode, competing with playback
-- transcoding for CPU and with streaming for the mount.
backfill_concurrency integer NOT NULL DEFAULT 2,
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT loudness_settings_singleton CHECK (id = true),
CONSTRAINT loudness_settings_concurrency_range
CHECK (backfill_concurrency >= 1 AND backfill_concurrency <= 8)
);
INSERT INTO loudness_settings (id) VALUES (true) ON CONFLICT (id) DO NOTHING;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS album_loudness;
@@ -0,0 +1,31 @@
-- 0066_album_loudness.up.sql — album loudness for album-mode normalization
-- (Scribe milestone #464, #4996).
--
-- An album's loudness is the gated loudness of every 400 ms block on the album,
-- not an average of its tracks' values: a quiet interlude and a loud single
-- should keep their difference when the album plays in order. It is computed
-- from the per-track block histograms in track_loudness (0065), summed, so no
-- audio is decoded again.
--
-- A derived value, recomputed by the loudness worker whenever its inputs
-- change. inputs_digest is an md5 over the album's present tracks and the
-- measurement each holds; the worker recomputes every album whose stored digest
-- no longer matches. That one comparison covers every way membership changes
-- (a scan moving a track between albums, a duplicate merge, a delete, a file
-- going missing or coming back) without hooking each of them.
CREATE TABLE album_loudness (
album_id uuid PRIMARY KEY REFERENCES albums (id) ON DELETE CASCADE,
-- NULL until every present track has a settled measurement: an album
-- leveled from half its tracks would jump when the rest arrived. Also NULL
-- for an album with no block above the gate. Clients fall back to track
-- gain while it is NULL.
integrated_lufs real,
-- The loudest true peak of any track on the album, so album gain is held
-- to the headroom of its loudest track.
true_peak_dbtp real,
tracks_total integer NOT NULL,
-- Tracks with a settled measurement (measured, silent or unreadable).
tracks_settled integer NOT NULL,
inputs_digest text NOT NULL,
computed_at timestamptz NOT NULL DEFAULT now()
);
+7
View File
@@ -68,3 +68,10 @@ UPDATE system_playlist_runs
DELETE FROM password_resets DELETE FROM password_resets
WHERE (used_at IS NOT NULL AND used_at < now() - INTERVAL '7 days') WHERE (used_at IS NOT NULL AND used_at < now() - INTERVAL '7 days')
OR (used_at IS NULL AND expires_at < now() - INTERVAL '1 hour'); OR (used_at IS NULL AND expires_at < now() - INTERVAL '1 hour');
-- name: GcDeleteExpiredSessions :execrows
-- Sessions past their idle (30 days) or absolute (1 year) limit. They already
-- fail auth through GetSessionByTokenHash's filter; this only clears the rows.
DELETE FROM sessions
WHERE last_seen_at <= now() - interval '30 days'
OR created_at <= now() - interval '365 days';
+171
View File
@@ -0,0 +1,171 @@
-- name: UpsertTrackLoudness :exec
-- Written when the backfill measures a track (#4995). Replaces the row
-- wholesale: a measurement of the old bytes has no standing once the file has
-- changed.
INSERT INTO track_loudness (
track_id, integrated_lufs, true_peak_dbtp, loudness_range_lu,
block_hist_start, block_hist, unreadable, analysis_version
) VALUES (
sqlc.arg(track_id), sqlc.narg(integrated_lufs), sqlc.narg(true_peak_dbtp),
sqlc.narg(loudness_range_lu), sqlc.narg(block_hist_start), sqlc.narg(block_hist),
sqlc.arg(unreadable), sqlc.arg(analysis_version)
)
ON CONFLICT (track_id) DO UPDATE SET
integrated_lufs = EXCLUDED.integrated_lufs,
true_peak_dbtp = EXCLUDED.true_peak_dbtp,
loudness_range_lu = EXCLUDED.loudness_range_lu,
block_hist_start = EXCLUDED.block_hist_start,
block_hist = EXCLUDED.block_hist,
unreadable = EXCLUDED.unreadable,
analysis_version = EXCLUDED.analysis_version,
analyzed_at = now();
-- name: DeleteTrackLoudness :exec
-- The scan saw new bytes at this path. The stored measurement describes the old
-- ones, so it goes, and the backfill measures the file again.
DELETE FROM track_loudness WHERE track_id = $1;
-- name: ListTracksNeedingLoudness :many
-- The backfill's work queue: tracks with no measurement, or one taken by an
-- older method. Keyset-paged on id so a pass visits each track at most once;
-- an inconclusive attempt writes no row, and without the cursor a file that
-- keeps timing out would be listed again straight away. Missing tracks are
-- skipped: there is no file to read.
SELECT t.id, t.file_path, t.duration_ms
FROM tracks t
LEFT JOIN track_loudness l ON l.track_id = t.id
WHERE t.missing_since IS NULL
AND (l.track_id IS NULL OR l.analysis_version < sqlc.arg(current_version))
AND t.id > sqlc.arg(after_id)
ORDER BY t.id
LIMIT sqlc.arg(batch_limit);
-- name: GetLoudnessCoverage :one
-- The admin gauge. measured + silent + unreadable + pending = total. Missing
-- tracks are excluded, or the gauge could never reach the end.
SELECT count(*)::bigint AS total,
count(*) FILTER (
WHERE l.analysis_version >= sqlc.arg(current_version)
AND l.integrated_lufs IS NOT NULL
)::bigint AS measured,
count(*) FILTER (
WHERE l.analysis_version >= sqlc.arg(current_version)
AND l.integrated_lufs IS NULL AND NOT l.unreadable
)::bigint AS silent,
count(*) FILTER (
WHERE l.analysis_version >= sqlc.arg(current_version)
AND l.unreadable
)::bigint AS unreadable,
count(*) FILTER (
WHERE l.track_id IS NULL OR l.analysis_version < sqlc.arg(current_version)
)::bigint AS pending
FROM tracks t
LEFT JOIN track_loudness l ON l.track_id = t.id
WHERE t.missing_since IS NULL;
-- name: GetLoudnessSettings :one
SELECT * FROM loudness_settings WHERE id = true;
-- name: UpdateLoudnessSettings :one
-- Whole-row write from the admin card; migration 0065's CHECK is the backstop
-- behind the service's own validation.
UPDATE loudness_settings
SET enabled = sqlc.arg(enabled),
backfill_concurrency = sqlc.arg(backfill_concurrency),
updated_at = now()
WHERE id = true
RETURNING *;
-- name: ListAlbumsNeedingLoudness :many
-- The album pass's work queue (#4996): albums whose present tracks or their
-- measurements have changed since album loudness was last computed, or that
-- never had it. The digest is over every present track's id and the
-- measurement it holds at the current version ('-' for none), so a track
-- joining, leaving or being re-measured changes it. Keyset-paged on album id
-- so a pass ends even if storing one album keeps failing.
WITH present AS (
SELECT t.album_id,
md5(string_agg(
t.id::text || ':' || coalesce(
l.analysis_version::text || '@' || l.analyzed_at::text, '-'),
',' ORDER BY t.id)) AS digest
FROM tracks t
LEFT JOIN track_loudness l
ON l.track_id = t.id AND l.analysis_version >= sqlc.arg(current_version)::smallint
WHERE t.missing_since IS NULL
GROUP BY t.album_id
)
SELECT c.album_id, c.digest::text AS digest
FROM present c
LEFT JOIN album_loudness a ON a.album_id = c.album_id
WHERE (a.album_id IS NULL OR a.inputs_digest <> c.digest)
-- Casts: sqlc cannot infer a parameter's type through a CTE alias.
AND c.album_id > sqlc.arg(after_id)::uuid
ORDER BY c.album_id
LIMIT sqlc.arg(batch_limit)::integer;
-- name: ListAlbumLoudnessInputs :many
-- Every present track on one album with its current measurement, if any.
-- settled is false for a track not yet measured at the current version.
SELECT t.id,
(l.track_id IS NOT NULL)::boolean AS settled,
l.true_peak_dbtp,
l.block_hist_start,
l.block_hist
FROM tracks t
LEFT JOIN track_loudness l
ON l.track_id = t.id AND l.analysis_version >= sqlc.arg(current_version)
WHERE t.album_id = sqlc.arg(album_id)
AND t.missing_since IS NULL;
-- name: UpsertAlbumLoudness :exec
INSERT INTO album_loudness (
album_id, integrated_lufs, true_peak_dbtp, tracks_total, tracks_settled, inputs_digest
) VALUES (
sqlc.arg(album_id), sqlc.narg(integrated_lufs), sqlc.narg(true_peak_dbtp),
sqlc.arg(tracks_total), sqlc.arg(tracks_settled), sqlc.arg(inputs_digest)
)
ON CONFLICT (album_id) DO UPDATE SET
integrated_lufs = EXCLUDED.integrated_lufs,
true_peak_dbtp = EXCLUDED.true_peak_dbtp,
tracks_total = EXCLUDED.tracks_total,
tracks_settled = EXCLUDED.tracks_settled,
inputs_digest = EXCLUDED.inputs_digest,
computed_at = now();
-- name: ListOrphanAlbumLoudness :many
-- Album rows whose album has no present track left (every file missing). The
-- album row itself survives a missing file, so its loudness would otherwise
-- stay behind describing tracks that are gone; it is recomputed if they return.
SELECT a.album_id
FROM album_loudness a
WHERE NOT EXISTS (
SELECT 1 FROM tracks t WHERE t.album_id = a.album_id AND t.missing_since IS NULL
);
-- name: DeleteAlbumLoudness :exec
DELETE FROM album_loudness WHERE album_id = ANY(sqlc.arg(album_ids)::uuid[]);
-- name: GetAlbumLoudness :one
-- The stored album values, read before a recompute so a sync change is
-- logged only when they actually move.
SELECT integrated_lufs, true_peak_dbtp FROM album_loudness WHERE album_id = $1;
-- name: GetReplayGainByTrackIDs :many
-- Track and album loudness for a set of tracks, for every surface that hands
-- gains to a client (#4997). A measurement from an older analysis version is
-- still delivered: it is a better gain than none until the backfill redoes it.
SELECT t.id,
tl.integrated_lufs AS track_lufs,
tl.true_peak_dbtp AS track_peak_dbtp,
al.integrated_lufs AS album_lufs,
al.true_peak_dbtp AS album_peak_dbtp
FROM tracks t
LEFT JOIN track_loudness tl ON tl.track_id = t.id
LEFT JOIN album_loudness al ON al.album_id = t.album_id
WHERE t.id = ANY(sqlc.arg(ids)::uuid[]);
-- name: GetAlbumLoudnessByIDs :many
SELECT album_id, integrated_lufs, true_peak_dbtp
FROM album_loudness
WHERE album_id = ANY(sqlc.arg(ids)::uuid[]);
+3
View File
@@ -3,3 +3,6 @@ SELECT * FROM network_settings WHERE id = true;
-- name: UpdateTrustedProxyHops :one -- name: UpdateTrustedProxyHops :one
UPDATE network_settings SET trusted_proxy_hops = $1 WHERE id = true RETURNING *; UPDATE network_settings SET trusted_proxy_hops = $1 WHERE id = true RETURNING *;
-- name: UpdatePublicURL :one
UPDATE network_settings SET public_url = $1 WHERE id = true RETURNING *;
+25 -2
View File
@@ -7,7 +7,18 @@ VALUES ($1, $2, $3, sqlc.arg(ip), sqlc.arg(ip))
RETURNING *; RETURNING *;
-- name: GetSessionByTokenHash :one -- name: GetSessionByTokenHash :one
SELECT * FROM sessions WHERE token_hash = $1; -- Expired sessions are invisible here, so they fail auth the moment they
-- lapse rather than whenever the GC sweep next runs. Two limits:
-- idle 30 days — a token nobody has used in a month is abandoned, and
-- matches the web cookie's lifetime;
-- absolute 1 year — even a token in daily use is re-issued yearly, so a
-- stolen one that is being used quietly does not live
-- forever.
-- Keep in step with ListSessionsForUser and GcDeleteExpiredSessions.
SELECT * FROM sessions
WHERE token_hash = $1
AND last_seen_at > now() - interval '30 days'
AND created_at > now() - interval '365 days';
-- name: TouchSessionLastSeen :exec -- name: TouchSessionLastSeen :exec
UPDATE sessions SET last_seen_at = now(), last_ip = $2 WHERE id = $1; UPDATE sessions SET last_seen_at = now(), last_ip = $2 WHERE id = $1;
@@ -15,7 +26,13 @@ UPDATE sessions SET last_seen_at = now(), last_ip = $2 WHERE id = $1;
-- name: ListSessionsForUser :many -- name: ListSessionsForUser :many
-- Most-recently-active first: the row a user is most likely to act on is the -- Most-recently-active first: the row a user is most likely to act on is the
-- one that moved last, and an unfamiliar entry at the top is the alarm. -- one that moved last, and an unfamiliar entry at the top is the alarm.
SELECT * FROM sessions WHERE user_id = $1 ORDER BY last_seen_at DESC; -- Expired rows are left out: they no longer authenticate, so listing them
-- as active would be wrong. Same limits as GetSessionByTokenHash.
SELECT * FROM sessions
WHERE user_id = $1
AND last_seen_at > now() - interval '30 days'
AND created_at > now() - interval '365 days'
ORDER BY last_seen_at DESC;
-- name: DeleteSession :exec -- name: DeleteSession :exec
DELETE FROM sessions WHERE id = $1; DELETE FROM sessions WHERE id = $1;
@@ -34,3 +51,9 @@ DELETE FROM sessions WHERE id = $1 AND user_id = $2;
-- "Log out everywhere else." Excludes the caller's own session so the action -- "Log out everywhere else." Excludes the caller's own session so the action
-- doesn't log them out of the page they just used to invoke it. -- doesn't log them out of the page they just used to invoke it.
DELETE FROM sessions WHERE user_id = $1 AND id <> $2; DELETE FROM sessions WHERE user_id = $1 AND id <> $2;
-- name: DeleteSessionsForUser :execrows
-- Every session the user has, the caller's included. A password reset uses
-- it: whoever forgot the password is not signed in anywhere they trust, and
-- whoever may have learned it must be signed out everywhere.
DELETE FROM sessions WHERE user_id = $1;
+11 -10
View File
@@ -1,5 +1,5 @@
-- name: CreateUser :one -- name: CreateUser :one
INSERT INTO users (username, password_hash, api_token, is_admin, display_name) INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
RETURNING *; RETURNING *;
@@ -17,7 +17,7 @@ RETURNING *;
-- and the second caller's INSERT fails with a unique violation. The -- and the second caller's INSERT fails with a unique violation. The
-- caller (registration handler) can retry as a regular non-admin in -- caller (registration handler) can retry as a regular non-admin in
-- that case (or surface a "username taken" error to the user). -- that case (or surface a "username taken" error to the user).
INSERT INTO users (username, password_hash, api_token, is_admin, display_name) INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name)
VALUES ( VALUES (
$1, $2, $3, $1, $2, $3,
(SELECT NOT EXISTS (SELECT 1 FROM users)), (SELECT NOT EXISTS (SELECT 1 FROM users)),
@@ -28,15 +28,16 @@ RETURNING *;
-- name: GetUserByUsername :one -- name: GetUserByUsername :one
SELECT * FROM users WHERE username = $1; SELECT * FROM users WHERE username = $1;
-- name: GetUserByAPIToken :one -- name: GetUserByAPITokenHash :one
SELECT * FROM users WHERE api_token = $1; SELECT * FROM users WHERE api_token_hash = $1;
-- name: CountUsers :one -- name: CountUsers :one
SELECT count(*) FROM users; SELECT count(*) FROM users;
-- name: SetSubsonicPassword :exec -- name: SetSubsonicPassword :exec
-- Stores (or clears with NULL) the per-user Subsonic legacy credential used -- Stores (or clears with NULL) the per-user Subsonic legacy credential used
-- for t/s and p auth on /rest/*. Must be plaintext; see migration 0003. -- for t/s and p auth on /rest/*. Must be plaintext; see migration 0003. Only
-- ever a server-generated value, never the login password (#5026).
UPDATE users SET subsonic_password = $2 WHERE id = $1; UPDATE users SET subsonic_password = $2 WHERE id = $1;
-- name: GetUserByID :one -- name: GetUserByID :one
@@ -87,7 +88,7 @@ WHERE u.id = $1;
-- Admin-driven user creation. Distinct from CreateUser/CreateUserFirstAdminRace: -- Admin-driven user creation. Distinct from CreateUser/CreateUserFirstAdminRace:
-- the caller (an admin) supplies all five fields explicitly including is_admin, -- the caller (an admin) supplies all five fields explicitly including is_admin,
-- so an admin can promote on creation. Used by POST /api/admin/users. -- so an admin can promote on creation. Used by POST /api/admin/users.
INSERT INTO users (username, password_hash, api_token, is_admin, display_name) INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
RETURNING *; RETURNING *;
@@ -141,11 +142,11 @@ UPDATE users
WHERE id = $1 WHERE id = $1
RETURNING *; RETURNING *;
-- name: RegenerateApiToken :one -- name: RegenerateApiToken :exec
-- Self-service: caller wants a new API token. Used by the /settings -- Self-service: caller wants a new API token. Used by the /settings
-- API Token card's "Regenerate" button. -- API Token card's "Regenerate" button. Only the hash is stored; the
UPDATE users SET api_token = $2 WHERE id = $1 -- handler returns the raw key once.
RETURNING *; UPDATE users SET api_token_hash = $2 WHERE id = $1;
-- name: GetUserByEmail :one -- name: GetUserByEmail :one
-- Used by forgot-password lookup. Lowercase comparison both sides -- Used by forgot-password lookup. Lowercase comparison both sides
+9
View File
@@ -91,6 +91,8 @@ var dataTables = []string{
"duplicate_groups", "duplicate_groups",
"duplicate_sweeps", "duplicate_sweeps",
"track_fingerprints", // M400 "track_fingerprints", // M400
"track_loudness", // M464
"album_loudness", // M464
"tracks", "tracks",
"albums", "albums",
"artists", "artists",
@@ -141,4 +143,11 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
); err != nil { ); err != nil {
t.Fatalf("dbtest.ResetDB reset fingerprint settings: %v", err) t.Fatalf("dbtest.ResetDB reset fingerprint settings: %v", err)
} }
// Loudness analysis settings (M464 #4995), reset the same way.
if _, err := pool.Exec(ctx, `
UPDATE loudness_settings
SET enabled = DEFAULT, backfill_concurrency = DEFAULT, updated_at = DEFAULT`,
); err != nil {
t.Fatalf("dbtest.ResetDB reset loudness settings: %v", err)
}
} }
+2
View File
@@ -16,6 +16,7 @@
// - GcExpireScrobbleQueueFailedRows (#567) // - GcExpireScrobbleQueueFailedRows (#567)
// - GcResetStuckSystemPlaylistRuns (#574) // - GcResetStuckSystemPlaylistRuns (#574)
// - GcDeleteExpiredPasswordResets (#575) // - GcDeleteExpiredPasswordResets (#575)
// - GcDeleteExpiredSessions (M462 #4978 — idle 30d / absolute 1y)
// - GcPruneDiagnostics (M9 — diagnostics 30d retention) // - GcPruneDiagnostics (M9 — diagnostics 30d retention)
// - GcDeleteExpiredSuggestionSnoozes (#2374 — snoozes expire, then go) // - GcDeleteExpiredSuggestionSnoozes (#2374 — snoozes expire, then go)
// - GcDeleteOrphanedCandidateArtistTags(+State) (#2376 — the similarity // - GcDeleteOrphanedCandidateArtistTags(+State) (#2376 — the similarity
@@ -86,6 +87,7 @@ func (w *Worker) tickOnce(ctx context.Context) {
w.runSweep(ctx, "expire_scrobble_failed", q.GcExpireScrobbleQueueFailedRows) w.runSweep(ctx, "expire_scrobble_failed", q.GcExpireScrobbleQueueFailedRows)
w.runSweep(ctx, "reset_stuck_system_runs", q.GcResetStuckSystemPlaylistRuns) w.runSweep(ctx, "reset_stuck_system_runs", q.GcResetStuckSystemPlaylistRuns)
w.runSweep(ctx, "delete_expired_password_resets", q.GcDeleteExpiredPasswordResets) w.runSweep(ctx, "delete_expired_password_resets", q.GcDeleteExpiredPasswordResets)
w.runSweep(ctx, "delete_expired_sessions", q.GcDeleteExpiredSessions)
w.runSweep(ctx, "prune_diagnostics", q.GcPruneDiagnostics) w.runSweep(ctx, "prune_diagnostics", q.GcPruneDiagnostics)
w.runSweep(ctx, "delete_expired_suggestion_snoozes", q.GcDeleteExpiredSuggestionSnoozes) w.runSweep(ctx, "delete_expired_suggestion_snoozes", q.GcDeleteExpiredSuggestionSnoozes)
// Tags before state: if the process dies between the two, a candidate left // Tags before state: if the process dies between the two, a candidate left
+1 -1
View File
@@ -50,7 +50,7 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, name string) pgtype.UUID {
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, Username: dbtest.TestUserPrefix + name,
PasswordHash: "test-hash", PasswordHash: "test-hash",
ApiToken: "test-token-" + name, ApiTokenHash: "test-token-" + name,
IsAdmin: false, IsAdmin: false,
}) })
if err != nil { if err != nil {
+212
View File
@@ -0,0 +1,212 @@
package library
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
// Album loudness (M464 #4996).
//
// Album-mode normalization plays a whole album at one gain, so the quiet
// interlude stays quieter than the single it sits between. That gain comes
// from the album's loudness: BS.1770's gated loudness over every block on the
// album, which is what summing the tracks' block histograms and gating the sum
// computes. An average of the tracks' values is not the same thing: gating
// over the whole album drops a near-silent hidden track, where averaging
// would let it drag the album quieter.
//
// The values are derived, so they are recomputed rather than maintained: each
// worker tick lists the albums whose inputs digest has moved (see
// ListAlbumsNeedingLoudness) and recomputes those from the stored histograms.
// albumLoudnessBatch is how many albums one query hands the album pass.
// Recomputing an album is a few small reads and arithmetic, no decode.
const albumLoudnessBatch = 200
// albumLoudness is one album's computed values.
type albumLoudness struct {
// integratedLUFS is nil until every track is settled, or when no block on
// the album passed the gate.
integratedLUFS *float32
truePeakDBTP *float32
total, settled int32
}
// computeAlbumLoudness sums the present tracks' histograms and gates the sum.
func computeAlbumLoudness(inputs []dbq.ListAlbumLoudnessInputsRow) albumLoudness {
a := albumLoudness{total: int32(len(inputs))}
hists := make([]blockHistogram, 0, len(inputs))
for _, in := range inputs {
if !in.Settled {
continue
}
a.settled++
if in.TruePeakDbtp != nil && (a.truePeakDBTP == nil || *in.TruePeakDbtp > *a.truePeakDBTP) {
peak := *in.TruePeakDbtp
a.truePeakDBTP = &peak
}
if in.BlockHistStart != nil && len(in.BlockHist) > 0 {
hists = append(hists, blockHistogram{start: *in.BlockHistStart, counts: in.BlockHist})
}
}
// Leveling from part of an album would change its gain as the rest is
// measured, audibly, mid-listen. Wait for all of it.
if a.total == 0 || a.settled < a.total {
return a
}
if lufs, ok := mergeHistograms(hists).gatedLoudness(); ok {
v := float32(lufs)
a.integratedLUFS = &v
}
return a
}
// mergeHistograms sums histograms that may cover different bin ranges into
// one, trimmed to the occupied range. A histogram reaching past the bin range
// (only a corrupt row could) is clipped rather than trusted.
func mergeHistograms(hs []blockHistogram) blockHistogram {
var bins [loudnessHistBins]int32
for _, h := range hs {
for i, c := range h.counts {
if b := int(h.start) + i; b >= 0 && b < loudnessHistBins {
bins[b] += c
}
}
}
return trimBins(&bins)
}
// AlbumLoudnessResult tallies one album pass.
type AlbumLoudnessResult struct {
Recomputed int
Leveled int // stored with an album loudness
Waiting int // stored without one: a track is not yet measured
Failed int
Orphans int64 // rows dropped because the album has no present track
}
// albumPass recomputes every album whose inputs changed, keyset-paged on album
// id so a pass ends even when one album keeps failing to store.
//
// The digest stored is the one the list query computed. If a track changes
// between that query and the read of its inputs, the stored digest is already
// stale and the next pass recomputes the album again, so the values always
// converge on the inputs.
func (w *LoudnessBackfillWorker) albumPass(ctx context.Context) (AlbumLoudnessResult, error) {
q := dbq.New(w.pool)
var res AlbumLoudnessResult
after := pgtype.UUID{Valid: true}
for {
if err := ctx.Err(); err != nil {
return res, err
}
rows, err := q.ListAlbumsNeedingLoudness(ctx, dbq.ListAlbumsNeedingLoudnessParams{
CurrentVersion: loudnessVersion,
AfterID: after,
BatchLimit: w.albumBatch,
})
if err != nil {
return res, fmt.Errorf("list albums needing loudness: %w", err)
}
if len(rows) == 0 {
break
}
for _, row := range rows {
res.Recomputed++
if err := w.storeAlbumLoudness(ctx, q, row.AlbumID, row.Digest, &res); err != nil {
res.Failed++
w.logger.Warn("album loudness: recompute failed", "album_id", row.AlbumID, "err", err)
}
}
after = rows[len(rows)-1].AlbumID
}
// Listed, logged, then deleted: the same log-first order as above.
orphans, err := q.ListOrphanAlbumLoudness(ctx)
if err != nil {
return res, fmt.Errorf("list orphan album loudness: %w", err)
}
if len(orphans) == 0 {
return res, nil
}
ids := make([]string, len(orphans))
for i, id := range orphans {
ids[i] = syncpkg.FormatUUID(id)
}
if err := syncpkg.LogChanges(ctx, w.pool, syncpkg.EntityAlbum, ids, syncpkg.OpUpsert); err != nil {
return res, fmt.Errorf("log orphan album changes: %w", err)
}
if err := q.DeleteAlbumLoudness(ctx, orphans); err != nil {
return res, fmt.Errorf("drop orphan album loudness: %w", err)
}
res.Orphans = int64(len(orphans))
return res, nil
}
// storeAlbumLoudness recomputes one album. The sync feed hears about it only
// when the values clients see move: during the backfill an album's digest
// changes with every track measured, and most of those recomputes still end
// with no album value.
func (w *LoudnessBackfillWorker) storeAlbumLoudness(
ctx context.Context, q *dbq.Queries, albumID pgtype.UUID, digest string, res *AlbumLoudnessResult,
) error {
before, err := q.GetAlbumLoudness(ctx, albumID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("read stored values: %w", err)
}
inputs, err := q.ListAlbumLoudnessInputs(ctx, dbq.ListAlbumLoudnessInputsParams{
CurrentVersion: loudnessVersion,
AlbumID: albumID,
})
if err != nil {
return fmt.Errorf("read inputs: %w", err)
}
a := computeAlbumLoudness(inputs)
// Logged before the write, for the reason storeLoudness gives (#2704). A
// failed log stores nothing, so the digest still differs and the next
// pass tries again.
if albumVisibleChange(before, a) {
if err := syncpkg.LogChange(ctx, w.pool, syncpkg.EntityAlbum, syncpkg.FormatUUID(albumID), syncpkg.OpUpsert); err != nil {
return fmt.Errorf("log album change: %w", err)
}
}
if err := q.UpsertAlbumLoudness(ctx, dbq.UpsertAlbumLoudnessParams{
AlbumID: albumID,
IntegratedLufs: a.integratedLUFS,
TruePeakDbtp: a.truePeakDBTP,
TracksTotal: a.total,
TracksSettled: a.settled,
InputsDigest: digest,
}); err != nil {
return fmt.Errorf("store: %w", err)
}
if a.integratedLUFS != nil {
res.Leveled++
} else {
res.Waiting++
}
return nil
}
// albumVisibleChange reports whether clients would see different album gains.
// The peak is only delivered beside a loudness, so a waiting album whose peak
// moves as tracks are measured has nothing new to tell anyone.
func albumVisibleChange(before dbq.GetAlbumLoudnessRow, after albumLoudness) bool {
if !sameFloat(before.IntegratedLufs, after.integratedLUFS) {
return true
}
return after.integratedLUFS != nil && !sameFloat(before.TruePeakDbtp, after.truePeakDBTP)
}
func sameFloat(a, b *float32) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
+275
View File
@@ -0,0 +1,275 @@
package library
import (
"context"
"io"
"log/slog"
"math"
"path/filepath"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
// histAt builds a histogram of n blocks all at one loudness.
func histAt(lufs float64, n int32) blockHistogram {
return blockHistogram{
start: int16(math.Round((lufs - loudnessHistFloor) * loudnessHistPerLU)),
counts: []int32{n},
}
}
func TestMergeHistograms_SumsAcrossDifferentRanges(t *testing.T) {
a := blockHistogram{start: 500, counts: []int32{1, 0, 2}} // bins 500..502
b := blockHistogram{start: 501, counts: []int32{4, 0, 0, 5}} // bins 501..504
got := mergeHistograms([]blockHistogram{a, b})
want := blockHistogram{start: 500, counts: []int32{1, 4, 2, 0, 5}}
if got.start != want.start || len(got.counts) != len(want.counts) {
t.Fatalf("merged = %+v, want %+v", got, want)
}
for i := range want.counts {
if got.counts[i] != want.counts[i] {
t.Fatalf("merged = %+v, want %+v", got, want)
}
}
// Bins past the range come only from a corrupt row; they are dropped, not
// allowed to index out of the array.
bad := blockHistogram{start: loudnessHistBins - 1, counts: []int32{1, 9}}
if got := mergeHistograms([]blockHistogram{bad}); len(got.counts) != 1 || got.counts[0] != 1 {
t.Errorf("out-of-range bin not dropped: %+v", got)
}
if !mergeHistograms(nil).empty() {
t.Errorf("merging nothing gave a non-empty histogram")
}
}
func input(settled bool, peak *float32, h blockHistogram) dbq.ListAlbumLoudnessInputsRow {
row := dbq.ListAlbumLoudnessInputsRow{Settled: settled, TruePeakDbtp: peak}
if !h.empty() {
start := h.start
row.BlockHistStart = &start
row.BlockHist = h.counts
}
return row
}
func TestComputeAlbumLoudness(t *testing.T) {
f := func(v float32) *float32 { return &v }
// Album loudness gates over the whole album. A near-silent hidden track is
// dropped by the relative gate, where averaging the tracks' values would
// have let it pull the album 15 LU quieter.
a := computeAlbumLoudness([]dbq.ListAlbumLoudnessInputsRow{
input(true, f(-1.0), histAt(-10, 1800)),
input(true, f(-0.2), histAt(-10, 2400)),
input(true, f(-30), histAt(-40, 600)),
})
if a.integratedLUFS == nil || math.Abs(float64(*a.integratedLUFS)-(-10)) > 0.01 {
t.Errorf("album loudness = %v, want -10 (the hidden track gated out)", a.integratedLUFS)
}
if a.truePeakDBTP == nil || *a.truePeakDBTP != -0.2 {
t.Errorf("album peak = %v, want the loudest track's -0.2", a.truePeakDBTP)
}
if a.total != 3 || a.settled != 3 {
t.Errorf("total/settled = %d/%d, want 3/3", a.total, a.settled)
}
// Energy, not an average of LUFS: two equally long tracks at -8 and -14
// make an album nearer the louder one than the midpoint (-11): -10.037.
a = computeAlbumLoudness([]dbq.ListAlbumLoudnessInputsRow{
input(true, nil, histAt(-8, 1000)),
input(true, nil, histAt(-14, 1000)),
})
if a.integratedLUFS == nil || math.Abs(float64(*a.integratedLUFS)-(-10.037)) > 0.01 {
t.Errorf("album loudness = %v, want -10.037", a.integratedLUFS)
}
// One track not yet measured: no album value until it is.
a = computeAlbumLoudness([]dbq.ListAlbumLoudnessInputsRow{
input(true, f(-1), histAt(-10, 100)),
input(false, nil, blockHistogram{}),
})
if a.integratedLUFS != nil || a.settled != 1 || a.total != 2 {
t.Errorf("partly measured album = %+v, want no loudness, 1 of 2 settled", a)
}
// Settled without blocks (silent, or unreadable): counted as settled and
// leveled from the rest.
a = computeAlbumLoudness([]dbq.ListAlbumLoudnessInputsRow{
input(true, f(-3), histAt(-12, 100)),
input(true, nil, blockHistogram{}),
})
if a.integratedLUFS == nil || math.Abs(float64(*a.integratedLUFS)-(-12)) > 0.01 {
t.Errorf("album with a silent track = %v, want -12 from the other track", a.integratedLUFS)
}
// Every track silent: settled, but nothing to level by.
a = computeAlbumLoudness([]dbq.ListAlbumLoudnessInputsRow{input(true, nil, blockHistogram{})})
if a.integratedLUFS != nil || a.settled != 1 {
t.Errorf("all-silent album = %+v, want no loudness", a)
}
}
// TestAlbumLoudness_Integration pins that the album pass computes from the
// stored histograms, does nothing when nothing changed, and recomputes on
// each kind of membership change.
func TestAlbumLoudness_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
q := dbq.New(pool)
dir := t.TempDir()
first, album, artist := seedTrack(t, pool, filepath.Join(dir, "a.mp3"))
addTrack := func(name string, albumID dbq.Album) dbq.Track {
t.Helper()
tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: name, AlbumID: albumID.ID, ArtistID: artist.ID,
DurationMs: 1000, FilePath: filepath.Join(dir, name+".mp3"), FileSize: 100, FileFormat: "mp3",
})
if err != nil {
t.Fatalf("track %s: %v", name, err)
}
return tr
}
measure := func(tr dbq.Track, lufs float64, peak float32) {
t.Helper()
h := histAt(lufs, 100)
l := float32(lufs)
if err := q.UpsertTrackLoudness(ctx, dbq.UpsertTrackLoudnessParams{
TrackID: tr.ID, IntegratedLufs: &l, TruePeakDbtp: &peak,
BlockHistStart: &h.start, BlockHist: h.counts, AnalysisVersion: loudnessVersion,
}); err != nil {
t.Fatalf("measure %s: %v", tr.Title, err)
}
}
read := func() (lufs, peak *float32, total, settled int32) {
t.Helper()
if err := pool.QueryRow(ctx,
"SELECT integrated_lufs, true_peak_dbtp, tracks_total, tracks_settled FROM album_loudness WHERE album_id = $1",
album.ID).Scan(&lufs, &peak, &total, &settled); err != nil {
t.Fatalf("read album loudness: %v", err)
}
return
}
w := NewLoudnessBackfillWorker(pool, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
w.albumBatch = 1 // forces the keyset cursor across queries
pass := func() AlbumLoudnessResult {
t.Helper()
res, err := w.albumPass(ctx)
if err != nil {
t.Fatalf("album pass: %v", err)
}
return res
}
albumChanges := func() int {
t.Helper()
var n int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM library_changes
WHERE entity_type = 'album' AND entity_id = $1`,
syncpkg.FormatUUID(album.ID)).Scan(&n); err != nil {
t.Fatalf("count album changes: %v", err)
}
return n
}
second := addTrack("b", album)
measure(first, -10, -1)
// 1. One track unmeasured: the album is stored, waiting, without loudness.
if res := pass(); res.Recomputed != 1 || res.Waiting != 1 {
t.Fatalf("first pass = %+v, want 1 recomputed, waiting", res)
}
if lufs, _, total, settled := read(); lufs != nil || total != 2 || settled != 1 {
t.Fatalf("waiting album = lufs %v, %d/%d settled; want nil, 1/2", lufs, settled, total)
}
// Still no album value: clients have nothing new to read (#4997).
if n := albumChanges(); n != 0 {
t.Fatalf("a waiting album logged %d sync changes, want 0", n)
}
// 2. Measuring the second track changes the digest; the album is leveled.
measure(second, -10, -0.5)
if res := pass(); res.Recomputed != 1 || res.Leveled != 1 {
t.Fatalf("second pass = %+v, want 1 leveled", res)
}
lufs, peak, _, _ := read()
if lufs == nil || math.Abs(float64(*lufs)-(-10)) > 0.01 || peak == nil || *peak != -0.5 {
t.Fatalf("leveled album = lufs %v peak %v, want -10 and -0.5", lufs, peak)
}
// Leveled: clients are told, once.
if n := albumChanges(); n != 1 {
t.Fatalf("leveling the album logged %d sync changes, want 1", n)
}
// 3. Nothing changed: nothing recomputed, nothing logged.
if res := pass(); res.Recomputed != 0 {
t.Fatalf("idle pass = %+v, want nothing recomputed", res)
}
if n := albumChanges(); n != 1 {
t.Fatalf("an idle pass logged album changes (now %d), want still 1", n)
}
// 4. A track joins (here, retagged onto this album): recomputed.
other, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "Other", SortTitle: "Other", ArtistID: artist.ID})
if err != nil {
t.Fatalf("other album: %v", err)
}
loud := addTrack("loud", other)
measure(loud, -4, 0.3)
pass()
if _, err := pool.Exec(ctx, "UPDATE tracks SET album_id = $1 WHERE id = $2", album.ID, loud.ID); err != nil {
t.Fatalf("move track: %v", err)
}
if res := pass(); res.Recomputed < 1 {
t.Fatalf("pass after a track joined = %+v, want a recompute", res)
}
// Two tracks at -10 and one at -4, equally long: -7.003 by energy.
if lufs, peak, total, _ := read(); total != 3 || lufs == nil || math.Abs(float64(*lufs)-(-7.003)) > 0.01 ||
peak == nil || *peak != 0.3 {
t.Fatalf("album after a loud track joined = lufs %v peak %v total %d", lufs, peak, total)
}
// 5. A track's file goes missing: recomputed without it.
if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE id = $1", loud.ID); err != nil {
t.Fatalf("mark missing: %v", err)
}
pass()
if lufs, peak, total, _ := read(); total != 2 || lufs == nil || math.Abs(float64(*lufs)-(-10)) > 0.01 ||
peak == nil || *peak != -0.5 {
t.Fatalf("album after the loud track went missing = lufs %v peak %v total %d", lufs, peak, total)
}
// 6. A track is deleted (as a duplicate merge does): recomputed.
if _, err := pool.Exec(ctx, "DELETE FROM tracks WHERE id = $1", second.ID); err != nil {
t.Fatalf("delete track: %v", err)
}
pass()
if _, _, total, _ := read(); total != 1 {
t.Fatalf("album after a delete has %d tracks, want 1", total)
}
// 7. Every remaining track missing: the album row is dropped, not left
// describing tracks that are gone.
if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE id = $1", first.ID); err != nil {
t.Fatalf("mark missing: %v", err)
}
before := albumChanges()
if res := pass(); res.Orphans != 1 {
t.Fatalf("pass with every track missing = %+v, want 1 orphan dropped", res)
}
// Dropping the row takes the album gain away, so clients are told.
if n := albumChanges(); n != before+1 {
t.Fatalf("dropping the orphan logged %d album changes, want 1", n-before)
}
var n int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM album_loudness WHERE album_id = $1", album.ID).Scan(&n); err != nil {
t.Fatalf("count: %v", err)
}
if n != 0 {
t.Fatalf("album loudness row survived every track going missing")
}
}
+1 -1
View File
@@ -74,7 +74,7 @@ func newMergeFixture(t *testing.T) mergeFixture {
user := func(name string) dbq.User { user := func(name string) dbq.User {
t.Helper() t.Helper()
u, err := q.CreateUser(ctx, dbq.CreateUserParams{ u, err := q.CreateUser(ctx, dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiToken: name + "-merge-token", Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiTokenHash: name + "-merge-token",
}) })
if err != nil { if err != nil {
t.Fatalf("user %s: %v", name, err) t.Fatalf("user %s: %v", name, err)
+410
View File
@@ -0,0 +1,410 @@
package library
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"log/slog"
"math"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
// Loudness analysis (M464 #4995).
//
// Every track is measured with ffmpeg's EBU R128 filter, which reports the
// ITU-R BS.1770 values loudness normalization works from:
//
// integrated loudness the gated, K-weighted average loudness of the whole
// track, in LUFS. A client levels a track by playing it
// at (target - integrated) dB.
// true peak the highest inter-sample peak, in dBTP. It bounds how
// far a quiet track can be raised before it clips.
// loudness range how much the loudness moves within the track.
//
// Tags are not trusted: ReplayGain values in the wild are written against four
// different reference levels, and most files have none.
//
// The filter also logs the loudness of every 400 ms gating block (one every
// 100 ms). Those are kept as a histogram, because an album's loudness is the
// gated loudness of every block on the album, not an average of its tracks'
// values. With the histograms stored, album loudness is exact and needs no
// second decode (#4996).
// loudnessVersion stamps how a track_loudness row was measured. Bump it when
// the measurement changes (the filter's options, the histogram's bins) and the
// backfill measures every row below it again.
const loudnessVersion int16 = 1
// Unlike a fingerprint, the analysis decodes the whole file, so a fixed
// deadline would either cut off a long mix or be useless for a three-minute
// song. The deadline is a base plus the track's length at loudnessMinSpeed:
// a decode slower than that is a stall, not a big file.
const (
loudnessBaseTimeout = 2 * time.Minute
loudnessMinSpeed = 4
)
// errLoudnessTimeout marks an analysis that ran out of time: a fact about the
// mount, not about the file.
var errLoudnessTimeout = errors.New("loudness analysis timed out")
// The block histogram's bins: 0.1 LU wide (the precision ffmpeg prints) from
// the -70 LUFS absolute gate up to +10 LUFS. Blocks below the gate do not take
// part in gating at all, so they are not counted; anything above the top bin,
// which only clipped noise reaches, is counted in it.
const (
loudnessHistFloor = -70.0
loudnessHistPerLU = 10
loudnessHistBins = 800
loudnessStderrTail = 20 // lines kept for an error message
)
// histogramCrossCheckLU is how far the loudness recomputed from the histogram
// may stray from ffmpeg's own figure before it is logged. They agree to a few
// hundredths when the log means what the parser assumes, so a larger gap says
// ffmpeg's output has changed underneath us.
const histogramCrossCheckLU = 0.3
// ebur128Args measures the file's first audio stream.
//
// peak=true asks for true peak (4x oversampled) rather than sample peak, which
// misses the inter-sample overs a boosted track would clip on. dualmono=true
// measures a mono file as if played on both speakers of a stereo pair, which is
// how it is heard; measured as one channel it would read 3 LU quiet and be
// boosted too far. framelog=info makes the per-block lines print at the log
// level asked for here, independent of ffmpeg's default.
func ebur128Args(path string) []string {
return []string{
"-hide_banner", "-nostdin", "-nostats",
"-loglevel", "info",
"-i", path,
"-map", "0:a:0",
"-af", "ebur128=peak=true:dualmono=true:framelog=info",
"-f", "null", "-",
}
}
// loudnessTimeout is the deadline for a track of durationMs. An unknown length
// (0) gets the base alone, which covers an ordinary song.
func loudnessTimeout(durationMs int32) time.Duration {
return loudnessBaseTimeout + time.Duration(max(durationMs, 0))*time.Millisecond/loudnessMinSpeed
}
// loudnessResult is one attempt at measuring a track.
type loudnessResult struct {
// integratedLUFS is nil when no block passed the gates: silence, or a
// file shorter than one 400 ms block.
integratedLUFS *float32
truePeakDBTP *float32
rangeLU *float32
hist blockHistogram
err error
}
// inconclusive reports whether the attempt failed for a reason that says
// nothing about the file. Such a result is never stored: stamped at the current
// version it would read as "this file cannot be measured", and the backfill
// would never try it again.
func (r loudnessResult) inconclusive() bool {
return isInconclusive(r.err) || errors.Is(r.err, errLoudnessTimeout)
}
// blockHistogram counts gating blocks per 0.1 LU bin, trimmed to the occupied
// range: counts[i] is the number of blocks measuring
// loudnessHistFloor + (start+i)/loudnessHistPerLU LUFS.
type blockHistogram struct {
start int16
counts []int32
}
func (h blockHistogram) empty() bool { return len(h.counts) == 0 }
// gatedLoudness applies BS.1770's two gates to the histogram and returns the
// integrated loudness of what passes. Every counted block is already above the
// absolute gate; the relative gate drops blocks more than 10 LU below the
// loudness of the blocks that passed it. ok is false when nothing passes.
//
// The same function measures an album, over the sum of its tracks'
// histograms (#4996).
func (h blockHistogram) gatedLoudness() (lufs float64, ok bool) {
energy := func(i int) float64 {
l := loudnessHistFloor + float64(int(h.start)+i)/loudnessHistPerLU
return math.Pow(10, (l+0.691)/10)
}
mean := func(threshold float64) (float64, bool) {
var sum, n float64
for i, c := range h.counts {
if c == 0 {
continue
}
if loudnessHistFloor+float64(int(h.start)+i)/loudnessHistPerLU < threshold {
continue
}
sum += float64(c) * energy(i)
n += float64(c)
}
if n == 0 {
return 0, false
}
return sum / n, true
}
ungated, ok := mean(math.Inf(-1))
if !ok {
return 0, false
}
relative := -0.691 + 10*math.Log10(ungated) - 10
gated, ok := mean(relative)
if !ok {
return 0, false
}
return -0.691 + 10*math.Log10(gated), true
}
// computeLoudness measures the file at path. durationMs sets the deadline.
func computeLoudness(ctx context.Context, path string, durationMs int32) loudnessResult {
timeout := loudnessTimeout(durationMs)
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(runCtx, "ffmpeg", ebur128Args(path)...)
cmd.WaitDelay = fingerprintWaitDelay
stderr, err := cmd.StderrPipe()
if err != nil {
return loudnessResult{err: fmt.Errorf("ffmpeg: %w", err)}
}
if err := cmd.Start(); err != nil {
return loudnessResult{err: fmt.Errorf("ffmpeg: %w", err)}
}
// The per-block log runs to ten lines a second of audio, about 12 MB for a
// two-hour mix, so it is parsed as it streams rather than buffered.
p := newEbur128Parser()
p.consume(stderr)
waitErr := cmd.Wait()
switch {
case ctx.Err() != nil:
// The caller gave up. Report that rather than the kill it caused, so it
// is never mistaken for a verdict on the file.
return loudnessResult{err: fmt.Errorf("ffmpeg: %w", ctx.Err())}
case errors.Is(runCtx.Err(), context.DeadlineExceeded):
return loudnessResult{err: fmt.Errorf("ffmpeg: no result within %s: %w", timeout, errLoudnessTimeout)}
case waitErr != nil:
var exitErr *exec.ExitError
if errors.As(waitErr, &exitErr) {
return loudnessResult{err: fmt.Errorf("ffmpeg exited %d: %s", exitErr.ExitCode(), p.tail())}
}
return loudnessResult{err: fmt.Errorf("ffmpeg: %w", waitErr)}
}
return p.result()
}
var (
// A per-block line: "[Parsed_ebur128_0 @ 0x…] t: 2.49998 TARGET:-23 LUFS
// M: -31.1 S:-120.7 I: -31.1 LUFS ...". M is the 400 ms block just ended.
ebur128BlockRe = regexp.MustCompile(`\bt:\s*\S+\s+TARGET:.*?\bM:\s*(-?(?:\d+(?:\.\d+)?|inf))`)
// The summary's values, each on a line of its own after "Summary:".
ebur128SummaryRe = regexp.MustCompile(`^(I|LRA|Peak):\s+(-?(?:\d+(?:\.\d+)?|inf))\s+(?:LUFS|LU|dBFS)$`)
)
// ebur128Parser reads the filter's log: the per-block lines into the
// histogram, and the closing summary.
type ebur128Parser struct {
bins [loudnessHistBins]int32
inSummary bool
summary map[string]string
lastLines []string
}
func newEbur128Parser() *ebur128Parser {
return &ebur128Parser{summary: map[string]string{}}
}
func (p *ebur128Parser) consume(r io.Reader) {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 4096), 64*1024)
for sc.Scan() {
p.line(sc.Text())
}
// A line past the buffer (not something ffmpeg prints) stops the scanner;
// drain the rest so ffmpeg is never blocked writing to a full pipe.
_, _ = io.Copy(io.Discard, r)
}
func (p *ebur128Parser) line(raw string) {
line := strings.TrimSpace(strings.TrimRight(raw, "\r"))
if line == "" {
return
}
if len(p.lastLines) == loudnessStderrTail {
p.lastLines = p.lastLines[1:]
}
p.lastLines = append(p.lastLines, line)
if strings.HasSuffix(line, "Summary:") {
p.inSummary = true
return
}
if p.inSummary {
if m := ebur128SummaryRe.FindStringSubmatch(line); m != nil {
p.summary[m[1]] = m[2]
}
return
}
m := ebur128BlockRe.FindStringSubmatch(line)
if m == nil {
return
}
v, err := strconv.ParseFloat(m[1], 64)
if err != nil || v < loudnessHistFloor {
// -inf, or below the absolute gate: such a block takes no part in
// gating.
return
}
bin := int(math.Round((v - loudnessHistFloor) * loudnessHistPerLU))
p.bins[min(bin, loudnessHistBins-1)]++
}
func (p *ebur128Parser) tail() string {
return strings.Join(p.lastLines, " | ")
}
func (p *ebur128Parser) histogram() blockHistogram {
return trimBins(&p.bins)
}
// trimBins turns a full-range bin array into a histogram trimmed to its
// occupied bins; an empty array gives an empty histogram.
func trimBins(bins *[loudnessHistBins]int32) blockHistogram {
first, last := 0, loudnessHistBins-1
for first <= last && bins[first] == 0 {
first++
}
if first > last {
return blockHistogram{}
}
for bins[last] == 0 {
last--
}
counts := make([]int32, last-first+1)
copy(counts, bins[first:last+1])
return blockHistogram{start: int16(first), counts: counts}
}
// result turns a clean exit into a measurement. A run with no summary means
// ffmpeg decoded nothing it could measure, which is a verdict on the file.
func (p *ebur128Parser) result() loudnessResult {
integrated, ok := p.summary["I"]
if !ok {
return loudnessResult{err: fmt.Errorf("ffmpeg printed no loudness summary: %s", p.tail())}
}
r := loudnessResult{
hist: p.histogram(),
truePeakDBTP: parseLoudnessValue(p.summary["Peak"]),
rangeLU: parseLoudnessValue(p.summary["LRA"]),
}
// ffmpeg reports -70.0 when no block passed the gate. The empty histogram
// says the same thing directly.
if !r.hist.empty() {
r.integratedLUFS = parseLoudnessValue(integrated)
}
if r.integratedLUFS == nil {
r.rangeLU = nil
}
return r
}
// parseLoudnessValue reads one summary figure; -inf and anything unreadable
// come back nil.
func parseLoudnessValue(s string) *float32 {
v, err := strconv.ParseFloat(s, 32)
if err != nil || math.IsInf(v, 0) || math.IsNaN(v) {
return nil
}
f := float32(v)
return &f
}
// loudnessOutcome is what storeLoudness did with one attempt.
type loudnessOutcome int
const (
loudnessMeasured loudnessOutcome = iota // integrated loudness stored
loudnessSilent // read fine; no block above the gate
loudnessUnreadable // ffmpeg could not decode the file
loudnessInconclusive // nothing stored; worth trying again
loudnessStoreFailed // the write itself failed
)
// storeLoudness records one attempt. It never fails its caller: an unmeasured
// track simply plays without a gain adjustment.
//
// An inconclusive attempt leaves any existing row alone. The scan deletes a
// row when its file changes, so a row still here describes these bytes, and a
// value from an older method is a better gain than none until it is redone.
//
// A stored measurement is logged to the sync feed as a track upsert, so
// clients that cache the library (Android) pick up the new gain (#4997). If
// the log fails nothing is stored, and the backfill tries the track again.
func storeLoudness(
ctx context.Context, db dbq.DBTX, logger *slog.Logger,
trackID pgtype.UUID, path string, r loudnessResult,
) loudnessOutcome {
q := dbq.New(db)
if r.err != nil {
logger.Warn("loudness: analysis failed", "path", path, "err", r.err)
if r.inconclusive() {
return loudnessInconclusive
}
}
params := dbq.UpsertTrackLoudnessParams{
TrackID: trackID,
Unreadable: r.err != nil,
AnalysisVersion: loudnessVersion,
}
outcome := loudnessUnreadable
if r.err == nil {
outcome = loudnessSilent
params.IntegratedLufs = r.integratedLUFS
params.TruePeakDbtp = r.truePeakDBTP
params.LoudnessRangeLu = r.rangeLU
if !r.hist.empty() {
start := r.hist.start
params.BlockHistStart = &start
params.BlockHist = r.hist.counts
}
if r.integratedLUFS != nil {
outcome = loudnessMeasured
if got, ok := r.hist.gatedLoudness(); ok && math.Abs(got-float64(*r.integratedLUFS)) > histogramCrossCheckLU {
// Stored regardless: ffmpeg's own figure is the track's
// loudness. But album loudness is computed from the
// histogram, and this says it would be wrong.
logger.Warn("loudness: block histogram disagrees with ffmpeg's integrated loudness",
"path", path, "ffmpeg_lufs", *r.integratedLUFS, "histogram_lufs", got)
}
}
}
// Logged before the write, as reconcile does (#2704): a log that then
// finds the write failed costs clients one wasted re-read, but a write
// that lands with no log is never retried, since the track is settled, and
// clients would never learn its gain.
if err := syncpkg.LogChange(ctx, db, syncpkg.EntityTrack, syncpkg.FormatUUID(trackID), syncpkg.OpUpsert); err != nil {
logger.Warn("loudness: LogChange track upsert failed; not storing", "path", path, "err", err)
return loudnessStoreFailed
}
if err := q.UpsertTrackLoudness(ctx, params); err != nil {
logger.Warn("loudness: storing measurement failed", "path", path, "err", err)
return loudnessStoreFailed
}
return outcome
}
+214
View File
@@ -0,0 +1,214 @@
package library
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Loudness backfill (M464 #4995).
//
// Every track is measured here, the new ones included: the scan only deletes a
// changed file's measurement (see scanFile), and this worker measures it again.
// Measuring inline in the scan was the first plan and was dropped, because the
// analysis decodes the whole file. Added to the scan, a large import would run
// several times longer and could pass StuckScanThreshold (1h), at which point
// the run is reaped and a second scan started beside it. The fingerprint
// backfill is a worker of its own for the same reason.
//
// Until a track is measured it plays with no gain adjustment, which is how
// every track played before normalization existed.
// loudnessBackfillTick is how often the worker looks for work. Shorter than
// the fingerprint backfill's hour, because a new track plays unleveled until it
// is measured; once the library has caught up, a tick is one indexed query.
const loudnessBackfillTick = 10 * time.Minute
// loudnessBackfillBatch is how many tracks one query hands the worker.
const loudnessBackfillBatch = 50
// loudnessBackfillConcurrency is the shipped value of the concurrency setting.
// Low for the reason fingerprinting's is: each analysis is a full decode,
// competing with playback transcoding and streaming.
const loudnessBackfillConcurrency = 2
// BackfillLoudnessResult tallies one pass.
type BackfillLoudnessResult struct {
Processed int
Measured int
Silent int // read fine, no block above the gate (settled)
Unreadable int // ffmpeg could not decode the file (settled)
Inconclusive int // nothing stored; tried again on a later pass
}
func (r *BackfillLoudnessResult) add(o loudnessOutcome) {
r.Processed++
switch o {
case loudnessMeasured:
r.Measured++
case loudnessSilent:
r.Silent++
case loudnessUnreadable:
r.Unreadable++
default:
r.Inconclusive++
}
}
// LoudnessBackfillWorker measures every track that has no current measurement.
type LoudnessBackfillWorker struct {
pool *pgxpool.Pool
logger *slog.Logger
settings *LoudnessSettingsService
tick time.Duration
batch int32
albumBatch int32
// analyze is a field so an integration test pins which tracks a pass
// touches, not what ffmpeg prints.
analyze func(ctx context.Context, path string, durationMs int32) loudnessResult
}
// NewLoudnessBackfillWorker builds a worker with the production cadence.
// settings is shared with the admin API; nil runs on defaults.
func NewLoudnessBackfillWorker(
pool *pgxpool.Pool, logger *slog.Logger, settings *LoudnessSettingsService,
) *LoudnessBackfillWorker {
return &LoudnessBackfillWorker{
pool: pool,
logger: logger,
settings: settings,
tick: loudnessBackfillTick,
batch: loudnessBackfillBatch,
albumBatch: albumLoudnessBatch,
analyze: computeLoudness,
}
}
// Run blocks until ctx is cancelled: one pass at start, then one per tick.
func (w *LoudnessBackfillWorker) Run(ctx context.Context) {
w.runOnce(ctx)
t := time.NewTicker(w.tick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
w.runOnce(ctx)
}
}
}
// runOnce contains a pass so that nothing it does (an error, a panic) can stop
// the next tick from firing (rule 157).
func (w *LoudnessBackfillWorker) runOnce(ctx context.Context) {
defer func() {
if r := recover(); r != nil {
w.logger.Error("loudness backfill: pass panicked", "panic", r)
}
}()
res, err := w.pass(ctx)
if err != nil && ctx.Err() == nil {
w.logger.Warn("loudness backfill: pass failed", "err", err, "processed", res.Processed)
}
if res.Processed > 0 {
w.logger.Info("loudness backfill: pass complete",
"processed", res.Processed, "measured", res.Measured, "silent", res.Silent,
"unreadable", res.Unreadable, "inconclusive", res.Inconclusive)
}
// Album loudness follows the tracks (#4996). It runs even with analysis
// switched off: it decodes nothing, and membership still changes as the
// library does.
albums, err := w.albumPass(ctx)
if err != nil && ctx.Err() == nil {
w.logger.Warn("album loudness: pass failed", "err", err, "recomputed", albums.Recomputed)
}
if albums.Recomputed > 0 || albums.Orphans > 0 {
w.logger.Info("album loudness: pass complete",
"recomputed", albums.Recomputed, "leveled", albums.Leveled,
"waiting", albums.Waiting, "failed", albums.Failed, "orphans", albums.Orphans)
}
}
// pass walks every track needing a measurement once, keyset-paged on id. The
// cursor is what lets a pass end: an inconclusive attempt writes no row, so a
// file that keeps timing out would otherwise be listed again immediately.
// Settings are read before every batch, so switching analysis off ends the
// pass and a new concurrency applies to the next batch.
func (w *LoudnessBackfillWorker) pass(ctx context.Context) (BackfillLoudnessResult, error) {
q := dbq.New(w.pool)
var (
res BackfillLoudnessResult
mu sync.Mutex
)
// The all-zero uuid sorts before every real id. Valid must be true: a NULL
// cursor would make "id > NULL" match nothing and every pass a no-op.
after := pgtype.UUID{Valid: true}
for {
if err := ctx.Err(); err != nil {
return res, err
}
cfg := w.settings.Get()
if !cfg.Enabled {
return res, nil
}
rows, err := q.ListTracksNeedingLoudness(ctx, dbq.ListTracksNeedingLoudnessParams{
CurrentVersion: loudnessVersion,
AfterID: after,
BatchLimit: w.batch,
})
if err != nil {
return res, fmt.Errorf("list tracks needing loudness: %w", err)
}
if len(rows) == 0 {
return res, nil
}
sem := make(chan struct{}, max(1, int(cfg.BackfillConcurrency)))
var wg sync.WaitGroup
for _, row := range rows {
if ctx.Err() != nil {
break
}
sem <- struct{}{}
wg.Add(1)
go func(row dbq.ListTracksNeedingLoudnessRow) {
defer wg.Done()
defer func() { <-sem }()
defer func() {
if r := recover(); r != nil {
w.logger.Error("loudness backfill: track panicked", "path", row.FilePath, "panic", r)
}
}()
outcome := storeLoudness(ctx, w.pool, w.logger, row.ID, row.FilePath,
w.analyzeFile(ctx, row.FilePath, row.DurationMs))
mu.Lock()
res.add(outcome)
mu.Unlock()
}(row)
}
wg.Wait()
after = rows[len(rows)-1].ID
}
}
func (w *LoudnessBackfillWorker) analyzeFile(ctx context.Context, path string, durationMs int32) loudnessResult {
if w.analyze == nil {
return computeLoudness(ctx, path, durationMs)
}
return w.analyze(ctx, path, durationMs)
}
// LoudnessCoverage reports how much of the library carries a current
// measurement, for the admin gauge. It lives beside the backfill so the
// version it counts against is the one the backfill writes.
func LoudnessCoverage(ctx context.Context, pool *pgxpool.Pool) (dbq.GetLoudnessCoverageRow, error) {
return dbq.New(pool).GetLoudnessCoverage(ctx, loudnessVersion)
}
+243
View File
@@ -0,0 +1,243 @@
package library
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"path/filepath"
"sync"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
// TestLoudnessBackfill_Integration pins which tracks a pass measures, what it
// stores for each kind of result, that a pass ends, and that the gauge counts
// what the passes wrote.
func TestLoudnessBackfill_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
q := dbq.New(pool)
dir := t.TempDir()
_, album, artist := seedTrack(t, pool, filepath.Join(dir, "unmeasured.mp3"))
addTrack := func(name string) dbq.Track {
t.Helper()
tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: name, AlbumID: album.ID, ArtistID: artist.ID,
DurationMs: 180000, FilePath: filepath.Join(dir, name+".mp3"), FileSize: 100, FileFormat: "mp3",
})
if err != nil {
t.Fatalf("track %s: %v", name, err)
}
return tr
}
lufs := func(v float32) *float32 { return &v }
current := addTrack("current")
stale := addTrack("stale")
missing := addTrack("missing")
for _, seed := range []struct {
track dbq.Track
version int16
}{
{current, loudnessVersion},
{stale, loudnessVersion - 1},
} {
if err := q.UpsertTrackLoudness(ctx, dbq.UpsertTrackLoudnessParams{
TrackID: seed.track.ID, IntegratedLufs: lufs(-9), AnalysisVersion: seed.version,
}); err != nil {
t.Fatalf("seed loudness: %v", err)
}
}
if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE id = $1", missing.ID); err != nil {
t.Fatalf("mark missing: %v", err)
}
settings, err := NewLoudnessSettingsService(ctx, pool)
if err != nil {
t.Fatalf("loudness settings: %v", err)
}
w := NewLoudnessBackfillWorker(pool, slog.New(slog.NewTextHandler(io.Discard, nil)), settings)
// A batch of one forces the keyset cursor across several queries in a pass.
w.batch = 1
var mu sync.Mutex
calls := map[string]int{}
durations := map[string]int32{}
start := int16(500)
w.analyze = func(_ context.Context, path string, durationMs int32) loudnessResult {
name := filepath.Base(path)
mu.Lock()
calls[name]++
durations[name] = durationMs
mu.Unlock()
switch name {
case "stall.mp3":
return loudnessResult{err: fmt.Errorf("ffmpeg: %w", errLoudnessTimeout)}
case "corrupt.mp3":
return loudnessResult{err: errors.New("ffmpeg exited 1: invalid data")}
case "silent.mp3":
return loudnessResult{}
default:
return loudnessResult{
integratedLUFS: lufs(-12.5), truePeakDBTP: lufs(-0.4), rangeLU: lufs(6),
hist: blockHistogram{start: start, counts: []int32{3, 0, 7}},
}
}
}
callCount := func(name string) int {
mu.Lock()
defer mu.Unlock()
return calls[name]
}
// 1. Only the unmeasured track and the stale one are measured, never the
// current one or the missing one, and the track's length reaches the
// analyzer (it sets the deadline).
res, err := w.pass(ctx)
if err != nil {
t.Fatalf("first pass: %v", err)
}
if res.Processed != 2 || res.Measured != 2 {
t.Fatalf("first pass = %+v, want 2 processed, 2 measured", res)
}
for name, want := range map[string]int{
"unmeasured.mp3": 1, "stale.mp3": 1, "current.mp3": 0, "missing.mp3": 0,
} {
if got := callCount(name); got != want {
t.Errorf("%s measured %d times, want %d", name, got, want)
}
}
if durations["stale.mp3"] != 180000 {
t.Errorf("analyzer got duration %d for stale.mp3, want 180000", durations["stale.mp3"])
}
var (
gotLUFS, gotPeak *float32
gotStart *int16
gotHist []int32
gotVersion int16
)
if err := pool.QueryRow(ctx, `SELECT integrated_lufs, true_peak_dbtp, block_hist_start, block_hist, analysis_version
FROM track_loudness WHERE track_id = $1`, stale.ID).
Scan(&gotLUFS, &gotPeak, &gotStart, &gotHist, &gotVersion); err != nil {
t.Fatalf("read stale row back: %v", err)
}
if gotLUFS == nil || *gotLUFS != -12.5 || gotPeak == nil || *gotPeak != -0.4 ||
gotStart == nil || *gotStart != start || len(gotHist) != 3 || gotHist[2] != 7 ||
gotVersion != loudnessVersion {
t.Errorf("stale row after re-measuring = lufs %v peak %v hist %v@%v version %d",
gotLUFS, gotPeak, gotHist, gotStart, gotVersion)
}
// Each stored measurement told the sync feed, so caching clients re-read
// the track and pick up its gain (#4997).
var logged int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM library_changes
WHERE entity_type = 'track' AND op = 'upsert' AND entity_id = $1`,
syncpkg.FormatUUID(stale.ID)).Scan(&logged); err != nil {
t.Fatalf("count sync changes: %v", err)
}
if logged != 1 {
t.Errorf("re-measuring stale.mp3 logged %d track changes, want 1", logged)
}
// 2. A pass after a complete one is a no-op.
res, err = w.pass(ctx)
if err != nil {
t.Fatalf("second pass: %v", err)
}
if res.Processed != 0 {
t.Fatalf("second pass processed %d tracks, want 0", res.Processed)
}
// 3. A stall is tried once and the pass ends; silence and a corrupt file are
// verdicts, stored and not tried again.
addTrack("stall")
addTrack("corrupt")
silent := addTrack("silent")
res, err = w.pass(ctx)
if err != nil {
t.Fatalf("third pass: %v", err)
}
if res.Processed != 3 || res.Inconclusive != 1 || res.Unreadable != 1 || res.Silent != 1 {
t.Fatalf("third pass = %+v, want 3 processed: 1 inconclusive, 1 unreadable, 1 silent", res)
}
if got := callCount("stall.mp3"); got != 1 {
t.Fatalf("stalling file tried %d times in one pass, want exactly 1", got)
}
var silentHist []int32
var silentUnreadable bool
if err := pool.QueryRow(ctx, "SELECT block_hist, unreadable FROM track_loudness WHERE track_id = $1",
silent.ID).Scan(&silentHist, &silentUnreadable); err != nil {
t.Fatalf("read silent row: %v", err)
}
if silentHist != nil || silentUnreadable {
t.Errorf("silent row = hist %v unreadable %v, want no histogram and readable", silentHist, silentUnreadable)
}
res, err = w.pass(ctx)
if err != nil {
t.Fatalf("fourth pass: %v", err)
}
if res.Processed != 1 || callCount("stall.mp3") != 2 || callCount("corrupt.mp3") != 1 {
t.Fatalf("fourth pass = %+v; want only the stalled file retried", res)
}
// 4. The gauge counts what the passes wrote, and its buckets add up. Six
// present tracks: unmeasured, current, stale, stall, corrupt, silent.
cov, err := LoudnessCoverage(ctx, pool)
if err != nil {
t.Fatalf("coverage: %v", err)
}
if cov.Total != 6 || cov.Measured != 3 || cov.Silent != 1 || cov.Unreadable != 1 || cov.Pending != 1 {
t.Errorf("coverage = %+v, want total 6, measured 3, silent 1, unreadable 1, pending 1", cov)
}
if cov.Measured+cov.Silent+cov.Unreadable+cov.Pending != cov.Total {
t.Errorf("coverage buckets %+v do not sum to the total", cov)
}
// 5. A changed file loses its measurement, so it is measured again.
if err := q.DeleteTrackLoudness(ctx, current.ID); err != nil {
t.Fatalf("delete loudness: %v", err)
}
// 6. Switched off, the backfill does nothing, even with work waiting.
off := DefaultLoudnessSettings
off.Enabled = false
if _, err := settings.Set(ctx, off); err != nil {
t.Fatalf("switch analysis off: %v", err)
}
res, err = w.pass(ctx)
if err != nil {
t.Fatalf("pass with analysis off: %v", err)
}
if res.Processed != 0 || callCount("current.mp3") != 0 {
t.Fatalf("pass with analysis off = %+v, want nothing done", res)
}
if _, err := settings.Set(ctx, DefaultLoudnessSettings); err != nil {
t.Fatalf("switch analysis on: %v", err)
}
res, err = w.pass(ctx)
if err != nil {
t.Fatalf("pass after switching back on: %v", err)
}
if callCount("current.mp3") != 1 {
t.Fatalf("changed file measured %d times after switching back on (pass %+v), want 1",
callCount("current.mp3"), res)
}
}
// The Go defaults must match the migration's, or a database that cannot be
// read would analyze differently from a fresh install.
func TestLoudnessSettings_DefaultsMatchMigration(t *testing.T) {
pool := newPool(t)
s, err := NewLoudnessSettingsService(context.Background(), pool)
if err != nil {
t.Fatalf("load: %v", err)
}
got := s.Get()
got.UpdatedAt = DefaultLoudnessSettings.UpdatedAt
if got != DefaultLoudnessSettings {
t.Errorf("migration defaults = %+v, Go defaults = %+v", got, DefaultLoudnessSettings)
}
}
+106
View File
@@ -0,0 +1,106 @@
package library
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Loudness analysis settings (M464 #4995). Rule 25: an operator setting is a
// database row, changed without a restart. One instance is shared by the
// backfill and the admin API, so a save reaches the worker at once.
// LoudnessSettings mirrors the loudness_settings row.
type LoudnessSettings struct {
// Enabled off stops the background analysis. Measured tracks keep their
// values, so normalization keeps working for them.
Enabled bool
BackfillConcurrency int32
// UpdatedAt is set by the database; ignored by Set.
UpdatedAt time.Time
}
// DefaultLoudnessSettings mirrors migration 0065's column defaults, so a
// database that cannot be read still analyzes the way a fresh install does.
var DefaultLoudnessSettings = LoudnessSettings{
Enabled: true,
BackfillConcurrency: loudnessBackfillConcurrency,
}
// ErrLoudnessSettingOutOfRange is returned by Set for a value migration 0065's
// CHECK would reject, so the API answers 400 naming the field.
var ErrLoudnessSettingOutOfRange = errors.New("loudness setting out of range")
// LoudnessSettingsService caches the settings and owns their persistence.
type LoudnessSettingsService struct {
pool *pgxpool.Pool
mu sync.RWMutex
cur LoudnessSettings
}
// NewLoudnessSettingsService loads once and caches. It always returns a usable
// service, holding the defaults when the load fails; the error says so.
func NewLoudnessSettingsService(ctx context.Context, pool *pgxpool.Pool) (*LoudnessSettingsService, error) {
s := &LoudnessSettingsService{pool: pool, cur: DefaultLoudnessSettings}
row, err := dbq.New(pool).GetLoudnessSettings(ctx)
if err != nil {
return s, fmt.Errorf("loudness settings: load: %w", err)
}
s.cur = loudnessSettingsFromRow(row)
return s, nil
}
// Get returns the cached settings. A nil service answers with the defaults.
func (s *LoudnessSettingsService) Get() LoudnessSettings {
if s == nil {
return DefaultLoudnessSettings
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.cur
}
// Set validates, persists and re-caches.
func (s *LoudnessSettingsService) Set(ctx context.Context, in LoudnessSettings) (LoudnessSettings, error) {
if err := validateLoudnessSettings(in); err != nil {
return LoudnessSettings{}, err
}
if s == nil {
return LoudnessSettings{}, errors.New("loudness settings: no settings service")
}
row, err := dbq.New(s.pool).UpdateLoudnessSettings(ctx, dbq.UpdateLoudnessSettingsParams{
Enabled: in.Enabled,
BackfillConcurrency: in.BackfillConcurrency,
})
if err != nil {
return LoudnessSettings{}, fmt.Errorf("loudness settings: save: %w", err)
}
out := loudnessSettingsFromRow(row)
s.mu.Lock()
s.cur = out
s.mu.Unlock()
return out, nil
}
func validateLoudnessSettings(in LoudnessSettings) error {
if in.BackfillConcurrency < minBackfillConcurrency || in.BackfillConcurrency > maxBackfillConcurrency {
return fmt.Errorf("%w: backfill_concurrency must be %d-%d",
ErrLoudnessSettingOutOfRange, minBackfillConcurrency, maxBackfillConcurrency)
}
return nil
}
func loudnessSettingsFromRow(row dbq.LoudnessSetting) LoudnessSettings {
return LoudnessSettings{
Enabled: row.Enabled,
BackfillConcurrency: row.BackfillConcurrency,
UpdatedAt: row.UpdatedAt.Time,
}
}
+250
View File
@@ -0,0 +1,250 @@
package library
import (
"context"
"errors"
"fmt"
"math"
"os"
"os/exec"
"slices"
"strings"
"testing"
"time"
)
// parseEbur128 runs the parser over a whole log, as computeLoudness does over
// ffmpeg's stderr.
func parseEbur128(log string) *ebur128Parser {
p := newEbur128Parser()
p.consume(strings.NewReader(log))
return p
}
// The fixture is real ffmpeg 6.1 output for 12 s of stereo tone whose first
// second is digital silence (testdata/ebur128_fixture.txt). Pinning the parser
// to captured output, not to a hand-written imitation of it, is the point: the
// per-block lines are where a format drift would hide.
func TestEbur128Parser_RealOutput(t *testing.T) {
raw, err := os.ReadFile("testdata/ebur128_fixture.txt")
if err != nil {
t.Fatalf("read fixture: %v", err)
}
r := parseEbur128(string(raw)).result()
if r.err != nil {
t.Fatalf("result err = %v", r.err)
}
for name, c := range map[string]struct {
got *float32
want float32
}{
"integrated": {r.integratedLUFS, -10.7},
"true peak": {r.truePeakDBTP, -5.6},
"range": {r.rangeLU, 2.0},
} {
if c.got == nil || *c.got != c.want {
t.Errorf("%s = %v, want %v", name, c.got, c.want)
}
}
// 120 blocks, of which the 10 covering the silent second are below the
// absolute gate and not counted. The loudest is -8.1 LUFS, the quietest
// that passed -22.6.
var blocks int32
for _, c := range r.hist.counts {
blocks += c
}
if blocks != 110 {
t.Errorf("histogram holds %d blocks, want 110", blocks)
}
if r.hist.start != 474 || len(r.hist.counts) != 146 {
t.Errorf("histogram spans bins %d..%d, want 474..619 (-22.6..-8.1 LUFS)",
r.hist.start, int(r.hist.start)+len(r.hist.counts)-1)
}
if r.hist.counts[0] == 0 || r.hist.counts[len(r.hist.counts)-1] == 0 {
t.Errorf("histogram not trimmed to its occupied bins: %v", r.hist.counts)
}
// Album loudness is computed from these histograms (#4996), so the
// histogram has to reproduce ffmpeg's own figure.
got, ok := r.hist.gatedLoudness()
if !ok || math.Abs(got-(-10.7)) > 0.05 {
t.Errorf("loudness from the histogram = %.3f (ok=%v), want ffmpeg's -10.7 within 0.05", got, ok)
}
}
func TestEbur128Parser_SilenceIsAVerdictNotAMeasurement(t *testing.T) {
log := `[Parsed_ebur128_0 @ 0x1] t: 0.1 TARGET:-23 LUFS M:-120.7 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
[Parsed_ebur128_0 @ 0x1] t: 0.2 TARGET:-23 LUFS M:-120.7 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
[Parsed_ebur128_0 @ 0x1] Summary:
Integrated loudness:
I: -70.0 LUFS
Threshold: 0.0 LUFS
Loudness range:
LRA: 0.0 LU
Threshold: 0.0 LUFS
LRA low: 0.0 LUFS
LRA high: 0.0 LUFS
True peak:
Peak: -inf dBFS
`
r := parseEbur128(log).result()
if r.err != nil {
t.Fatalf("err = %v, want a clean result", r.err)
}
if r.integratedLUFS != nil || r.truePeakDBTP != nil || r.rangeLU != nil {
t.Errorf("silence measured as integrated=%v peak=%v range=%v, want all nil",
r.integratedLUFS, r.truePeakDBTP, r.rangeLU)
}
if !r.hist.empty() {
t.Errorf("silence left blocks in the histogram: %+v", r.hist)
}
if r.inconclusive() {
t.Errorf("silence reported as inconclusive; it is settled until the file changes")
}
}
func TestEbur128Parser_NoSummaryIsUnreadable(t *testing.T) {
r := parseEbur128("[in#0 @ 0x1] Error opening input: Invalid data found when processing input\n").result()
if r.err == nil {
t.Fatal("a log with no summary produced a measurement")
}
if r.inconclusive() {
t.Errorf("err %v reads as inconclusive; ffmpeg ran and found nothing to measure", r.err)
}
if !strings.Contains(r.err.Error(), "Invalid data found") {
t.Errorf("err %q does not carry ffmpeg's reason", r.err)
}
}
func TestEbur128Parser_LoudBlocksLandInTheTopBin(t *testing.T) {
p := parseEbur128(`[Parsed_ebur128_0 @ 0x1] t: 0.4 TARGET:-23 LUFS M: 12.4 S: 12.4 I: 12.4 LUFS
[Parsed_ebur128_0 @ 0x1] t: 0.5 TARGET:-23 LUFS M: -5.0 S: -5.0 I: -5.0 LUFS
`)
h := p.histogram()
if int(h.start)+len(h.counts) != loudnessHistBins || h.counts[len(h.counts)-1] != 1 {
t.Errorf("a +12.4 LUFS block was not counted in the top bin: %+v", h)
}
if h.start != 650 || h.counts[0] != 1 {
t.Errorf("a -5.0 LUFS block is not in bin 650: start %d, first %d", h.start, h.counts[0])
}
}
func TestBlockHistogram_GatedLoudness(t *testing.T) {
bin := func(lufs float64) int { return int(math.Round((lufs - loudnessHistFloor) * loudnessHistPerLU)) }
hist := func(blocks map[float64]int32) blockHistogram {
lo, hi := loudnessHistBins, 0
for l := range blocks {
lo, hi = min(lo, bin(l)), max(hi, bin(l))
}
h := blockHistogram{start: int16(lo), counts: make([]int32, hi-lo+1)}
for l, n := range blocks {
h.counts[bin(l)-lo] = n
}
return h
}
cases := []struct {
name string
blocks map[float64]int32
want float64
}{
// One level throughout is that level.
{"steady", map[float64]int32{-14: 50}, -14},
// The quiet half is 30 LU below the loud half, past the relative gate
// (10 LU below the ungated loudness, here about -13), so it is dropped
// and the result is the loud half alone.
{"quiet passage gated out", map[float64]int32{-10: 100, -40: 100}, -10},
// 6 LU apart is inside the gate, so both count, energy-weighted: the
// result sits nearer the louder level than the midpoint (-15) does.
{"both inside the gate", map[float64]int32{-12: 100, -18: 100}, -14.037},
}
for _, c := range cases {
got, ok := hist(c.blocks).gatedLoudness()
if !ok || math.Abs(got-c.want) > 0.01 {
t.Errorf("%s: gatedLoudness = %.3f (ok=%v), want %.3f", c.name, got, ok, c.want)
}
}
if _, ok := (blockHistogram{}).gatedLoudness(); ok {
t.Errorf("an empty histogram reported a loudness")
}
}
func TestEbur128Args(t *testing.T) {
args := ebur128Args("/music/a.flac")
joined := strings.Join(args, " ")
for _, want := range []string{"peak=true", "dualmono=true", "framelog=info", "-map 0:a:0", "-nostdin", "-f null -"} {
if !strings.Contains(joined, want) {
t.Errorf("args %q lack %q", joined, want)
}
}
// The path is its own argument, never spliced into the filter string.
if i := slices.Index(args, "-i"); i < 0 || args[i+1] != "/music/a.flac" {
t.Errorf("args %q do not pass the path after -i", args)
}
}
func TestLoudnessTimeout_ScalesWithLength(t *testing.T) {
if got := loudnessTimeout(0); got != loudnessBaseTimeout {
t.Errorf("unknown length: %s, want the base %s", got, loudnessBaseTimeout)
}
if got, want := loudnessTimeout(int32((2 * time.Hour).Milliseconds())), loudnessBaseTimeout+30*time.Minute; got != want {
t.Errorf("two-hour mix: %s, want %s", got, want)
}
if got := loudnessTimeout(-5); got != loudnessBaseTimeout {
t.Errorf("negative length: %s, want the base %s", got, loudnessBaseTimeout)
}
}
func TestLoudnessResult_Inconclusive(t *testing.T) {
for _, err := range []error{
fmt.Errorf("ffmpeg: %w", errLoudnessTimeout),
fmt.Errorf("ffmpeg: %w", context.Canceled),
fmt.Errorf("ffmpeg: %w", exec.ErrNotFound),
} {
if !(loudnessResult{err: err}).inconclusive() {
t.Errorf("%v: not inconclusive, so it would be stored as a verdict", err)
}
}
if (loudnessResult{err: errors.New("ffmpeg exited 1: moov atom not found")}).inconclusive() {
t.Errorf("a decode failure read as inconclusive; it would be retried every pass")
}
if (loudnessResult{}).inconclusive() {
t.Errorf("a clean result read as inconclusive")
}
}
func TestBackfillLoudnessResult_Add(t *testing.T) {
var r BackfillLoudnessResult
for _, o := range []loudnessOutcome{
loudnessMeasured, loudnessMeasured, loudnessSilent, loudnessUnreadable,
loudnessInconclusive, loudnessStoreFailed,
} {
r.add(o)
}
// A failed write stored nothing, so it is retried like an inconclusive one.
want := BackfillLoudnessResult{Processed: 6, Measured: 2, Silent: 1, Unreadable: 1, Inconclusive: 2}
if r != want {
t.Errorf("tally = %+v, want %+v", r, want)
}
}
func TestValidateLoudnessSettings(t *testing.T) {
for _, n := range []int32{minBackfillConcurrency, maxBackfillConcurrency} {
if err := validateLoudnessSettings(LoudnessSettings{BackfillConcurrency: n}); err != nil {
t.Errorf("concurrency %d rejected: %v", n, err)
}
}
for _, n := range []int32{0, maxBackfillConcurrency + 1} {
if err := validateLoudnessSettings(LoudnessSettings{BackfillConcurrency: n}); !errors.Is(err, ErrLoudnessSettingOutOfRange) {
t.Errorf("concurrency %d: err = %v, want ErrLoudnessSettingOutOfRange", n, err)
}
}
var nilSvc *LoudnessSettingsService
if got := nilSvc.Get(); got != DefaultLoudnessSettings {
t.Errorf("nil service Get = %+v, want the defaults", got)
}
}
+102
View File
@@ -0,0 +1,102 @@
package library
import (
"context"
"math"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// ReplayGain delivery (M464 #4997).
//
// Clients are handed loudness in the ReplayGain 2.0 form every player already
// understands: a gain in dB that brings the track (or album) to the RG2
// reference of -18 LUFS, and a linear peak. A client aiming at another target
// adds (target - ReplayGainReferenceLUFS) to the gain. The same numbers go to
// the native API, the sync feed and OpenSubsonic's replayGain, so every client
// levels a track identically.
// ReplayGainReferenceLUFS is the RG2 reference loudness gains are relative to.
const ReplayGainReferenceLUFS = -18.0
// ReplayGain is one track's gains. Each field is nil when there is nothing to
// say: not yet measured, silent, or (for the album pair) an album still
// waiting on a track. AlbumPeak travels only with AlbumGain.
type ReplayGain struct {
TrackGain *float32 `json:"track_gain,omitempty"`
TrackPeak *float32 `json:"track_peak,omitempty"`
AlbumGain *float32 `json:"album_gain,omitempty"`
AlbumPeak *float32 `json:"album_peak,omitempty"`
}
// Empty reports whether no gain is known at all.
func (g ReplayGain) Empty() bool {
return g.TrackGain == nil && g.AlbumGain == nil
}
// GainDB converts an integrated loudness to its RG2 gain, to 0.01 dB.
func GainDB(lufs *float32) *float32 {
if lufs == nil {
return nil
}
v := float32(math.Round((ReplayGainReferenceLUFS-float64(*lufs))*100) / 100)
return &v
}
// LinearPeak converts a true peak in dBTP to the linear amplitude ReplayGain
// peaks are written in (1.0 is full scale), to 4 decimals.
func LinearPeak(dbtp *float32) *float32 {
if dbtp == nil {
return nil
}
v := float32(math.Round(math.Pow(10, float64(*dbtp)/20)*10000) / 10000)
return &v
}
// ReplayGainForTracks returns the gains for each of ids that has any. Tracks
// with none are absent from the map, so a lookup of a missing key yields the
// zero ReplayGain: no adjustment.
func ReplayGainForTracks(ctx context.Context, q *dbq.Queries, ids []pgtype.UUID) (map[pgtype.UUID]ReplayGain, error) {
out := make(map[pgtype.UUID]ReplayGain, len(ids))
if len(ids) == 0 {
return out, nil
}
rows, err := q.GetReplayGainByTrackIDs(ctx, ids)
if err != nil {
return nil, err
}
for _, r := range rows {
g := ReplayGain{TrackGain: GainDB(r.TrackLufs)}
if g.TrackGain != nil {
g.TrackPeak = LinearPeak(r.TrackPeakDbtp)
}
if g.AlbumGain = GainDB(r.AlbumLufs); g.AlbumGain != nil {
g.AlbumPeak = LinearPeak(r.AlbumPeakDbtp)
}
if !g.Empty() {
out[r.ID] = g
}
}
return out, nil
}
// ReplayGainForAlbums returns the album pair for each of albumIDs that has an
// album loudness. The track fields are left nil.
func ReplayGainForAlbums(ctx context.Context, q *dbq.Queries, albumIDs []pgtype.UUID) (map[pgtype.UUID]ReplayGain, error) {
out := make(map[pgtype.UUID]ReplayGain, len(albumIDs))
if len(albumIDs) == 0 {
return out, nil
}
rows, err := q.GetAlbumLoudnessByIDs(ctx, albumIDs)
if err != nil {
return nil, err
}
for _, r := range rows {
if g := GainDB(r.IntegratedLufs); g != nil {
out[r.AlbumID] = ReplayGain{AlbumGain: g, AlbumPeak: LinearPeak(r.TruePeakDbtp)}
}
}
return out, nil
}
+122
View File
@@ -0,0 +1,122 @@
package library
import (
"context"
"math"
"path/filepath"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func TestGainDBAndLinearPeak(t *testing.T) {
f := func(v float32) *float32 { return &v }
for _, c := range []struct {
lufs, want float32
}{
{-18, 0}, // at the reference: no change
{-9.5, -8.5}, // a loud master is turned down
{-23.456, 5.46}, // a quiet one up, to 0.01 dB
} {
if got := GainDB(f(c.lufs)); got == nil || *got != c.want {
t.Errorf("GainDB(%v) = %v, want %v", c.lufs, got, c.want)
}
}
if GainDB(nil) != nil {
t.Errorf("GainDB(nil) is not nil")
}
for _, c := range []struct {
dbtp, want float32
}{
{0, 1}, // full scale
{-6.0206, 0.5}, // half amplitude
{1.2, 1.1482}, // an inter-sample over, above 1.0
} {
got := LinearPeak(f(c.dbtp))
if got == nil || math.Abs(float64(*got-c.want)) > 0.0001 {
t.Errorf("LinearPeak(%v) = %v, want %v", c.dbtp, got, c.want)
}
}
if LinearPeak(nil) != nil {
t.Errorf("LinearPeak(nil) is not nil")
}
}
func TestReplayGainForTracks_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
q := dbq.New(pool)
dir := t.TempDir()
measured, album, artist := seedTrack(t, pool, filepath.Join(dir, "measured.mp3"))
add := func(name string) dbq.Track {
t.Helper()
tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: name, AlbumID: album.ID, ArtistID: artist.ID,
DurationMs: 1000, FilePath: filepath.Join(dir, name+".mp3"), FileSize: 100, FileFormat: "mp3",
})
if err != nil {
t.Fatalf("track %s: %v", name, err)
}
return tr
}
older := add("older")
unmeasured := add("unmeasured")
f := func(v float32) *float32 { return &v }
for _, m := range []struct {
tr dbq.Track
lufs float32
version int16
}{
{measured, -12, loudnessVersion},
// A measurement by an older method is still a better gain than none.
{older, -20, loudnessVersion - 1},
} {
if err := q.UpsertTrackLoudness(ctx, dbq.UpsertTrackLoudnessParams{
TrackID: m.tr.ID, IntegratedLufs: f(m.lufs), TruePeakDbtp: f(-1), AnalysisVersion: m.version,
}); err != nil {
t.Fatalf("seed: %v", err)
}
}
gains, err := ReplayGainForTracks(ctx, q, []pgtype.UUID{measured.ID, older.ID, unmeasured.ID})
if err != nil {
t.Fatalf("lookup: %v", err)
}
if g := gains[measured.ID]; g.TrackGain == nil || *g.TrackGain != -6 || g.TrackPeak == nil {
t.Errorf("measured track gains = %+v, want track gain -6 with a peak", g)
}
if g := gains[older.ID]; g.TrackGain == nil || *g.TrackGain != 2 {
t.Errorf("older-version track gains = %+v, want track gain 2", g)
}
if _, ok := gains[unmeasured.ID]; ok {
t.Errorf("unmeasured track has an entry; absent means no adjustment")
}
// No album value yet (none computed): the album pair is absent.
if g := gains[measured.ID]; g.AlbumGain != nil || g.AlbumPeak != nil {
t.Errorf("album pair present before album loudness exists: %+v", g)
}
if err := q.UpsertAlbumLoudness(ctx, dbq.UpsertAlbumLoudnessParams{
AlbumID: album.ID, IntegratedLufs: f(-14), TruePeakDbtp: f(-0.5),
TracksTotal: 3, TracksSettled: 3, InputsDigest: "x",
}); err != nil {
t.Fatalf("seed album: %v", err)
}
gains, err = ReplayGainForTracks(ctx, q, []pgtype.UUID{unmeasured.ID})
if err != nil {
t.Fatalf("lookup: %v", err)
}
if g := gains[unmeasured.ID]; g.TrackGain != nil || g.AlbumGain == nil || *g.AlbumGain != -4 || g.AlbumPeak == nil {
t.Errorf("unmeasured track on a leveled album = %+v, want only the album pair (gain -4)", g)
}
byAlbum, err := ReplayGainForAlbums(ctx, q, []pgtype.UUID{album.ID})
if err != nil {
t.Fatalf("album lookup: %v", err)
}
if g := byAlbum[album.ID]; g.AlbumGain == nil || *g.AlbumGain != -4 || g.TrackGain != nil {
t.Errorf("album gains = %+v, want album gain -4 and no track pair", g)
}
}
+7
View File
@@ -402,6 +402,13 @@ func (s *Scanner) scanFile(
// must not outlive them, or the sweep would compare audio that is gone. // must not outlive them, or the sweep would compare audio that is gone.
s.logger.Warn("fingerprint: clearing stale fingerprint failed", "path", path, "err", err) s.logger.Warn("fingerprint: clearing stale fingerprint failed", "path", path, "err", err)
} }
// Loudness is measured by its own worker, never inline: it decodes the
// whole file (see loudness_backfill.go). The scan's part is to drop a
// measurement of bytes that are gone, so clients stop leveling this
// track by the old file's loudness and the worker measures it again.
if err := q.DeleteTrackLoudness(ctx, track.ID); err != nil {
s.logger.Warn("loudness: clearing stale measurement failed", "path", path, "err", err)
}
} }
if knownTrack { if knownTrack {
+149
View File
@@ -0,0 +1,149 @@
Input #0, flac, from 'fixture.flac':
Metadata:
encoder : Lavf60.16.100
Duration: 00:00:12.00, start: 0.000000, bitrate: 789 kb/s
Stream #0:0: Audio: flac, 44100 Hz, stereo, s32 (24 bit)
Stream mapping:
Stream #0:0 -> #0:0 (flac (native) -> pcm_s16le (native))
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.0999773 TARGET:-23 LUFS M:-120.7 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
Output #0, null, to 'pipe:':
Metadata:
encoder : Lavf60.16.100
Stream #0:0: Audio: pcm_s16le, 44100 Hz, stereo, s16, 1411 kb/s
Metadata:
encoder : Lavc60.31.102 pcm_s16le
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.199977 TARGET:-23 LUFS M:-120.7 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.299977 TARGET:-23 LUFS M:-120.7 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.399977 TARGET:-23 LUFS M:-163.2 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.499977 TARGET:-23 LUFS M:-163.2 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.599977 TARGET:-23 LUFS M:-163.2 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.699977 TARGET:-23 LUFS M:-163.2 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.799977 TARGET:-23 LUFS M:-163.2 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.899977 TARGET:-23 LUFS M:-163.2 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 0.999977 TARGET:-23 LUFS M:-163.2 S:-120.7 I: -70.0 LUFS LRA: 0.0 LU FTPK: -inf -inf dBFS TPK: -inf -inf dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.09998 TARGET:-23 LUFS M: -15.4 S:-120.7 I: -15.4 LUFS LRA: 0.0 LU FTPK: -7.1 -10.7 dBFS TPK: -7.1 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.19998 TARGET:-23 LUFS M: -12.2 S:-120.7 I: -13.5 LUFS LRA: 0.0 LU FTPK: -6.6 -10.9 dBFS TPK: -6.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.29998 TARGET:-23 LUFS M: -10.3 S:-120.7 I: -12.2 LUFS LRA: 0.0 LU FTPK: -6.3 -11.1 dBFS TPK: -6.3 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.39998 TARGET:-23 LUFS M: -9.0 S:-120.7 I: -11.1 LUFS LRA: 0.0 LU FTPK: -6.0 -11.4 dBFS TPK: -6.0 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.49998 TARGET:-23 LUFS M: -8.8 S:-120.7 I: -10.6 LUFS LRA: 0.0 LU FTPK: -5.8 -11.7 dBFS TPK: -5.8 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.59998 TARGET:-23 LUFS M: -8.7 S:-120.7 I: -10.2 LUFS LRA: 0.0 LU FTPK: -5.7 -12.1 dBFS TPK: -5.7 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.69998 TARGET:-23 LUFS M: -8.6 S:-120.7 I: -9.9 LUFS LRA: 0.0 LU FTPK: -5.6 -12.4 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.79998 TARGET:-23 LUFS M: -8.5 S:-120.7 I: -9.7 LUFS LRA: 0.0 LU FTPK: -5.6 -12.8 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.89998 TARGET:-23 LUFS M: -8.6 S:-120.7 I: -9.6 LUFS LRA: 0.0 LU FTPK: -5.6 -13.3 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 1.99998 TARGET:-23 LUFS M: -8.6 S:-120.7 I: -9.5 LUFS LRA: 0.0 LU FTPK: -5.7 -13.8 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.09998 TARGET:-23 LUFS M: -8.8 S:-120.7 I: -9.4 LUFS LRA: 0.0 LU FTPK: -5.8 -14.3 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.19998 TARGET:-23 LUFS M: -8.9 S:-120.7 I: -9.4 LUFS LRA: 0.0 LU FTPK: -6.0 -14.9 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.29998 TARGET:-23 LUFS M: -9.2 S:-120.7 I: -9.3 LUFS LRA: 0.0 LU FTPK: -6.3 -15.6 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.39998 TARGET:-23 LUFS M: -9.5 S:-120.7 I: -9.4 LUFS LRA: 0.0 LU FTPK: -6.7 -16.3 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.49998 TARGET:-23 LUFS M: -9.9 S:-120.7 I: -9.4 LUFS LRA: 0.0 LU FTPK: -7.1 -17.2 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.59998 TARGET:-23 LUFS M: -10.4 S:-120.7 I: -9.5 LUFS LRA: 0.0 LU FTPK: -7.7 -18.1 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.69998 TARGET:-23 LUFS M: -11.0 S:-120.7 I: -9.5 LUFS LRA: 0.0 LU FTPK: -8.3 -19.2 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.79998 TARGET:-23 LUFS M: -11.6 S:-120.7 I: -9.6 LUFS LRA: 0.0 LU FTPK: -9.1 -20.4 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.89998 TARGET:-23 LUFS M: -12.4 S:-120.7 I: -9.7 LUFS LRA: 0.0 LU FTPK: -10.0 -21.8 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 2.99998 TARGET:-23 LUFS M: -13.4 S: -11.5 I: -9.9 LUFS LRA: 20.0 LU FTPK: -11.1 -23.6 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.09998 TARGET:-23 LUFS M: -14.5 S: -11.5 I: -10.0 LUFS LRA: 20.0 LU FTPK: -12.5 -25.8 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.19998 TARGET:-23 LUFS M: -15.8 S: -11.4 I: -10.1 LUFS LRA: 20.0 LU FTPK: -14.1 -28.2 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.29998 TARGET:-23 LUFS M: -17.3 S: -11.4 I: -10.3 LUFS LRA: 20.0 LU FTPK: -16.2 -25.4 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.39998 TARGET:-23 LUFS M: -19.1 S: -11.4 I: -10.5 LUFS LRA: 0.1 LU FTPK: -19.2 -23.3 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.49998 TARGET:-23 LUFS M: -21.1 S: -11.4 I: -10.5 LUFS LRA: 0.1 LU FTPK: -23.7 -21.6 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.59998 TARGET:-23 LUFS M: -22.6 S: -11.4 I: -10.5 LUFS LRA: 0.1 LU FTPK: -22.6 -20.2 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.69998 TARGET:-23 LUFS M: -22.5 S: -11.4 I: -10.5 LUFS LRA: 0.1 LU FTPK: -18.5 -19.0 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.79998 TARGET:-23 LUFS M: -20.9 S: -11.3 I: -10.6 LUFS LRA: 0.2 LU FTPK: -15.8 -17.9 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.89998 TARGET:-23 LUFS M: -18.9 S: -11.3 I: -10.9 LUFS LRA: 0.2 LU FTPK: -13.8 -17.0 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 3.99998 TARGET:-23 LUFS M: -17.1 S: -11.2 I: -11.0 LUFS LRA: 0.2 LU FTPK: -12.2 -16.2 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.09998 TARGET:-23 LUFS M: -15.6 S: -11.4 I: -11.1 LUFS LRA: 0.2 LU FTPK: -10.9 -15.5 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.19998 TARGET:-23 LUFS M: -14.3 S: -11.5 I: -11.2 LUFS LRA: 0.2 LU FTPK: -9.8 -14.8 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.29998 TARGET:-23 LUFS M: -13.2 S: -11.7 I: -11.3 LUFS LRA: 0.4 LU FTPK: -8.9 -14.2 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.39998 TARGET:-23 LUFS M: -12.3 S: -11.8 I: -11.3 LUFS LRA: 0.4 LU FTPK: -8.2 -13.7 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.49998 TARGET:-23 LUFS M: -11.5 S: -11.9 I: -11.3 LUFS LRA: 0.5 LU FTPK: -7.6 -13.2 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.59998 TARGET:-23 LUFS M: -10.8 S: -12.0 I: -11.3 LUFS LRA: 0.6 LU FTPK: -7.0 -12.8 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.69998 TARGET:-23 LUFS M: -10.2 S: -12.0 I: -11.2 LUFS LRA: 0.7 LU FTPK: -6.6 -12.4 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.79998 TARGET:-23 LUFS M: -9.7 S: -12.1 I: -11.2 LUFS LRA: 0.7 LU FTPK: -6.3 -12.0 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.89998 TARGET:-23 LUFS M: -9.3 S: -12.1 I: -11.1 LUFS LRA: 0.8 LU FTPK: -6.0 -11.7 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 4.99998 TARGET:-23 LUFS M: -8.9 S: -12.1 I: -11.0 LUFS LRA: 0.8 LU FTPK: -5.8 -11.4 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.09998 TARGET:-23 LUFS M: -8.6 S: -12.0 I: -11.0 LUFS LRA: 0.8 LU FTPK: -5.7 -11.1 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.19998 TARGET:-23 LUFS M: -8.4 S: -11.9 I: -10.8 LUFS LRA: 0.8 LU FTPK: -5.6 -10.8 dBFS TPK: -5.6 -10.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.29998 TARGET:-23 LUFS M: -8.3 S: -11.8 I: -10.7 LUFS LRA: 0.8 LU FTPK: -5.6 -10.6 dBFS TPK: -5.6 -10.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.39998 TARGET:-23 LUFS M: -8.2 S: -11.7 I: -10.5 LUFS LRA: 0.8 LU FTPK: -5.6 -10.4 dBFS TPK: -5.6 -10.4 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.49998 TARGET:-23 LUFS M: -8.1 S: -11.5 I: -10.4 LUFS LRA: 0.8 LU FTPK: -5.7 -10.2 dBFS TPK: -5.6 -10.2 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.59998 TARGET:-23 LUFS M: -8.1 S: -11.4 I: -10.4 LUFS LRA: 0.8 LU FTPK: -5.8 -10.1 dBFS TPK: -5.6 -10.1 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.69998 TARGET:-23 LUFS M: -8.2 S: -11.2 I: -10.3 LUFS LRA: 0.8 LU FTPK: -6.0 -10.0 dBFS TPK: -5.6 -10.0 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.79998 TARGET:-23 LUFS M: -8.3 S: -11.1 I: -10.2 LUFS LRA: 0.8 LU FTPK: -6.3 -9.9 dBFS TPK: -5.6 -9.9 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.89998 TARGET:-23 LUFS M: -8.4 S: -10.9 I: -10.2 LUFS LRA: 1.0 LU FTPK: -6.7 -9.8 dBFS TPK: -5.6 -9.8 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 5.99998 TARGET:-23 LUFS M: -8.7 S: -10.7 I: -10.2 LUFS LRA: 1.0 LU FTPK: -7.2 -9.7 dBFS TPK: -5.6 -9.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.09998 TARGET:-23 LUFS M: -8.9 S: -10.6 I: -10.1 LUFS LRA: 1.2 LU FTPK: -7.7 -9.7 dBFS TPK: -5.6 -9.7 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.19998 TARGET:-23 LUFS M: -9.2 S: -10.4 I: -10.1 LUFS LRA: 1.3 LU FTPK: -8.4 -9.6 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.29998 TARGET:-23 LUFS M: -9.6 S: -10.3 I: -10.1 LUFS LRA: 1.5 LU FTPK: -9.2 -9.6 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.39998 TARGET:-23 LUFS M: -10.0 S: -10.2 I: -10.1 LUFS LRA: 1.6 LU FTPK: -10.1 -9.6 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.49998 TARGET:-23 LUFS M: -10.4 S: -10.0 I: -10.1 LUFS LRA: 1.8 LU FTPK: -11.2 -9.6 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.59998 TARGET:-23 LUFS M: -10.9 S: -10.0 I: -10.1 LUFS LRA: 1.9 LU FTPK: -12.6 -9.7 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.69998 TARGET:-23 LUFS M: -11.4 S: -9.9 I: -10.1 LUFS LRA: 2.0 LU FTPK: -14.3 -9.7 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.79998 TARGET:-23 LUFS M: -11.9 S: -9.8 I: -10.2 LUFS LRA: 2.1 LU FTPK: -16.5 -9.8 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.89998 TARGET:-23 LUFS M: -12.5 S: -9.8 I: -10.2 LUFS LRA: 2.1 LU FTPK: -19.5 -9.9 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 6.99998 TARGET:-23 LUFS M: -12.9 S: -9.8 I: -10.2 LUFS LRA: 2.2 LU FTPK: -24.3 -10.0 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.09998 TARGET:-23 LUFS M: -13.3 S: -9.8 I: -10.3 LUFS LRA: 2.2 LU FTPK: -22.2 -10.1 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.19998 TARGET:-23 LUFS M: -13.5 S: -9.8 I: -10.3 LUFS LRA: 2.2 LU FTPK: -18.2 -10.3 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.29998 TARGET:-23 LUFS M: -13.5 S: -9.8 I: -10.3 LUFS LRA: 2.2 LU FTPK: -15.6 -10.5 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.39998 TARGET:-23 LUFS M: -13.3 S: -9.9 I: -10.4 LUFS LRA: 2.2 LU FTPK: -13.6 -10.7 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.49998 TARGET:-23 LUFS M: -13.0 S: -9.9 I: -10.4 LUFS LRA: 2.2 LU FTPK: -12.0 -10.9 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.59998 TARGET:-23 LUFS M: -12.6 S: -10.0 I: -10.4 LUFS LRA: 2.2 LU FTPK: -10.8 -11.2 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.69998 TARGET:-23 LUFS M: -12.1 S: -10.0 I: -10.5 LUFS LRA: 2.2 LU FTPK: -9.7 -11.5 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.79998 TARGET:-23 LUFS M: -11.7 S: -10.1 I: -10.5 LUFS LRA: 2.2 LU FTPK: -8.9 -11.8 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.89998 TARGET:-23 LUFS M: -11.2 S: -10.1 I: -10.5 LUFS LRA: 2.2 LU FTPK: -8.1 -12.1 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 7.99998 TARGET:-23 LUFS M: -10.8 S: -10.2 I: -10.5 LUFS LRA: 2.2 LU FTPK: -7.5 -12.5 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.09998 TARGET:-23 LUFS M: -10.4 S: -10.3 I: -10.5 LUFS LRA: 2.2 LU FTPK: -7.0 -12.9 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.19998 TARGET:-23 LUFS M: -10.1 S: -10.4 I: -10.5 LUFS LRA: 2.2 LU FTPK: -6.6 -13.4 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.29998 TARGET:-23 LUFS M: -9.8 S: -10.4 I: -10.5 LUFS LRA: 2.2 LU FTPK: -6.2 -13.9 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.39998 TARGET:-23 LUFS M: -9.5 S: -10.5 I: -10.5 LUFS LRA: 2.2 LU FTPK: -6.0 -14.4 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.49998 TARGET:-23 LUFS M: -9.3 S: -10.5 I: -10.4 LUFS LRA: 2.2 LU FTPK: -5.8 -15.0 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.59998 TARGET:-23 LUFS M: -9.2 S: -10.6 I: -10.4 LUFS LRA: 2.2 LU FTPK: -5.7 -15.7 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.69998 TARGET:-23 LUFS M: -9.1 S: -10.6 I: -10.4 LUFS LRA: 2.2 LU FTPK: -5.6 -16.5 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.79998 TARGET:-23 LUFS M: -9.0 S: -10.6 I: -10.4 LUFS LRA: 2.2 LU FTPK: -5.6 -17.3 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.89998 TARGET:-23 LUFS M: -9.0 S: -10.6 I: -10.4 LUFS LRA: 2.2 LU FTPK: -5.6 -18.3 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 8.99998 TARGET:-23 LUFS M: -9.1 S: -10.7 I: -10.3 LUFS LRA: 2.2 LU FTPK: -5.7 -19.4 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.09998 TARGET:-23 LUFS M: -9.2 S: -10.7 I: -10.3 LUFS LRA: 2.2 LU FTPK: -5.9 -20.6 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.19998 TARGET:-23 LUFS M: -9.4 S: -10.7 I: -10.3 LUFS LRA: 2.2 LU FTPK: -6.1 -22.1 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.29998 TARGET:-23 LUFS M: -9.7 S: -10.7 I: -10.3 LUFS LRA: 2.2 LU FTPK: -6.4 -23.9 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.39998 TARGET:-23 LUFS M: -10.0 S: -10.7 I: -10.3 LUFS LRA: 2.1 LU FTPK: -6.7 -26.3 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.49998 TARGET:-23 LUFS M: -10.4 S: -10.7 I: -10.3 LUFS LRA: 2.1 LU FTPK: -7.2 -27.7 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.59998 TARGET:-23 LUFS M: -10.9 S: -10.7 I: -10.3 LUFS LRA: 2.1 LU FTPK: -7.8 -25.0 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.69998 TARGET:-23 LUFS M: -11.4 S: -10.7 I: -10.3 LUFS LRA: 2.1 LU FTPK: -8.4 -23.0 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.79998 TARGET:-23 LUFS M: -12.0 S: -10.7 I: -10.3 LUFS LRA: 2.1 LU FTPK: -9.2 -21.3 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.89998 TARGET:-23 LUFS M: -12.7 S: -10.7 I: -10.4 LUFS LRA: 2.1 LU FTPK: -10.2 -20.0 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 9.99998 TARGET:-23 LUFS M: -13.5 S: -10.7 I: -10.4 LUFS LRA: 2.1 LU FTPK: -11.3 -18.8 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.1 TARGET:-23 LUFS M: -14.4 S: -10.7 I: -10.4 LUFS LRA: 2.1 LU FTPK: -12.7 -17.8 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.2 TARGET:-23 LUFS M: -15.3 S: -10.8 I: -10.4 LUFS LRA: 2.1 LU FTPK: -14.5 -16.9 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.3 TARGET:-23 LUFS M: -16.2 S: -10.8 I: -10.5 LUFS LRA: 2.1 LU FTPK: -16.7 -16.1 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.4 TARGET:-23 LUFS M: -17.1 S: -10.9 I: -10.5 LUFS LRA: 2.1 LU FTPK: -19.8 -15.4 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.5 TARGET:-23 LUFS M: -17.8 S: -11.0 I: -10.6 LUFS LRA: 2.1 LU FTPK: -24.9 -14.7 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.6 TARGET:-23 LUFS M: -18.0 S: -11.1 I: -10.6 LUFS LRA: 2.1 LU FTPK: -21.7 -14.1 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.7 TARGET:-23 LUFS M: -17.6 S: -11.1 I: -10.6 LUFS LRA: 2.1 LU FTPK: -17.9 -13.6 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.8 TARGET:-23 LUFS M: -16.7 S: -11.2 I: -10.7 LUFS LRA: 2.1 LU FTPK: -15.4 -13.1 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 10.9 TARGET:-23 LUFS M: -15.7 S: -11.3 I: -10.7 LUFS LRA: 2.1 LU FTPK: -13.4 -12.7 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11 TARGET:-23 LUFS M: -14.6 S: -11.4 I: -10.7 LUFS LRA: 2.1 LU FTPK: -11.9 -12.3 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.1 TARGET:-23 LUFS M: -13.6 S: -11.5 I: -10.8 LUFS LRA: 2.1 LU FTPK: -10.7 -11.9 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.2 TARGET:-23 LUFS M: -12.7 S: -11.6 I: -10.8 LUFS LRA: 2.1 LU FTPK: -9.6 -11.6 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.3 TARGET:-23 LUFS M: -11.8 S: -11.7 I: -10.8 LUFS LRA: 2.1 LU FTPK: -8.8 -11.3 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.4 TARGET:-23 LUFS M: -11.1 S: -11.7 I: -10.8 LUFS LRA: 2.0 LU FTPK: -8.1 -11.0 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.5 TARGET:-23 LUFS M: -10.5 S: -11.7 I: -10.8 LUFS LRA: 2.0 LU FTPK: -7.4 -10.8 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.6 TARGET:-23 LUFS M: -9.9 S: -11.8 I: -10.8 LUFS LRA: 2.0 LU FTPK: -6.9 -10.6 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.7 TARGET:-23 LUFS M: -9.5 S: -11.8 I: -10.8 LUFS LRA: 2.0 LU FTPK: -6.5 -10.4 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.8 TARGET:-23 LUFS M: -9.0 S: -11.7 I: -10.7 LUFS LRA: 2.0 LU FTPK: -6.2 -10.2 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 11.9 TARGET:-23 LUFS M: -8.7 S: -11.7 I: -10.7 LUFS LRA: 2.0 LU FTPK: -5.9 -10.1 dBFS TPK: -5.6 -9.6 dBFS
[Parsed_ebur128_0 @ 0x5ebc43357b00] t: 12 TARGET:-23 LUFS M: -8.4 S: -11.6 I: -10.7 LUFS LRA: 2.0 LU FTPK: -5.8 -10.0 dBFS TPK: -5.6 -9.6 dBFS
[out#0/null @ 0x5ebc43345c00] video:0kB audio:2067kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: unknown
size=N/A time=00:00:11.90 bitrate=N/A speed= 161x
[Parsed_ebur128_0 @ 0x5ebc43357b00] Summary:
Integrated loudness:
I: -10.7 LUFS
Threshold: -20.8 LUFS
Loudness range:
LRA: 2.0 LU
Threshold: -30.9 LUFS
LRA low: -11.8 LUFS
LRA high: -9.8 LUFS
True peak:
Peak: -5.6 dBFS
+1 -1
View File
@@ -46,7 +46,7 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
t.Helper() t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x", Username: dbtest.TestUserPrefix + name, PasswordHash: "x",
ApiToken: name + "-token", IsAdmin: false, ApiTokenHash: name + "-token", IsAdmin: false,
}) })
if err != nil { if err != nil {
t.Fatalf("seed user %s: %v", name, err) t.Fatalf("seed user %s: %v", name, err)
+2 -2
View File
@@ -50,7 +50,7 @@ func newPool(t *testing.T) *pgxpool.Pool {
func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID { func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID {
t.Helper() t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "rqtester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, Username: dbtest.TestUserPrefix + "rqtester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false,
}) })
if err != nil { if err != nil {
t.Fatalf("seed user: %v", err) t.Fatalf("seed user: %v", err)
@@ -206,7 +206,7 @@ func TestListForUser_OnlyOwnRows(t *testing.T) {
pool := newPool(t) pool := newPool(t)
alice := seedUser(t, pool) alice := seedUser(t, pool)
bob, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ bob, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiToken: "x2", IsAdmin: false, Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiTokenHash: "x2", IsAdmin: false,
}) })
if err != nil { if err != nil {
t.Fatalf("seed bob: %v", err) t.Fatalf("seed bob: %v", err)
+34
View File
@@ -0,0 +1,34 @@
package netsettings
import "testing"
func TestNormalizePublicURL(t *testing.T) {
ok := map[string]string{
"": "",
" ": "",
"https://music.example.com": "https://music.example.com",
"https://music.example.com/": "https://music.example.com",
"http://192.168.1.10:4533": "http://192.168.1.10:4533",
" https://Music.Example.com/ ": "https://Music.Example.com",
}
for in, want := range ok {
got, err := NormalizePublicURL(in)
if err != nil || got != want {
t.Errorf("NormalizePublicURL(%q) = %q, %v; want %q", in, got, err, want)
}
}
for _, in := range []string{
"music.example.com", // no scheme
"ftp://music.example.com", // wrong scheme
"https://", // no host
"https://music.example.com/app", // path
"https://music.example.com/?x=1", // query
"https://music.example.com/#frag", // fragment
"https://user:pw@music.example.com",
"javascript:alert(1)",
} {
if _, err := NormalizePublicURL(in); err == nil {
t.Errorf("NormalizePublicURL(%q) accepted, want ErrInvalidPublicURL", in)
}
}
}
+60 -2
View File
@@ -12,6 +12,8 @@ import (
"context" "context"
"errors" "errors"
"log/slog" "log/slog"
"net/url"
"strings"
"sync" "sync"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@@ -32,13 +34,18 @@ const (
// so the API layer can answer 400 instead of surfacing a constraint violation. // so the API layer can answer 400 instead of surfacing a constraint violation.
var ErrHopsOutOfRange = errors.New("trusted proxy hops must be between 0 and 10") var ErrHopsOutOfRange = errors.New("trusted proxy hops must be between 0 and 10")
// ErrInvalidPublicURL is returned by SetPublicURL for anything that isn't a
// bare http(s) origin, so the API layer can answer 400.
var ErrInvalidPublicURL = errors.New("public URL must be an http:// or https:// address with a host and no path, query or fragment")
// Service caches the network settings and owns their persistence. // Service caches the network settings and owns their persistence.
type Service struct { type Service struct {
pool *pgxpool.Pool pool *pgxpool.Pool
logger *slog.Logger logger *slog.Logger
mu sync.RWMutex mu sync.RWMutex
hops int hops int
publicURL string
} }
// New loads the settings once and caches them. // New loads the settings once and caches them.
@@ -58,6 +65,7 @@ func New(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*Service
return s, err return s, err
} }
s.hops = int(row.TrustedProxyHops) s.hops = int(row.TrustedProxyHops)
s.publicURL = row.PublicUrl
return s, nil return s, nil
} }
@@ -106,3 +114,53 @@ func (s *Service) SetHops(ctx context.Context, hops int) error {
} }
return nil return nil
} }
// PublicURL returns the operator-set address users reach Minstrel at, with no
// trailing slash, or "" when it hasn't been set. Links that leave the app (a
// password-reset email) are built from this and never from the request's
// Host header, which the requester controls. Nil-safe like Hops.
func (s *Service) PublicURL() string {
if s == nil {
return ""
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.publicURL
}
// NormalizePublicURL validates raw as a bare http(s) origin and returns it
// without a trailing slash. "" is valid and means unset.
func NormalizePublicURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil
}
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" ||
u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" {
return "", ErrInvalidPublicURL
}
return u.Scheme + "://" + u.Host, nil
}
// SetPublicURL validates, persists and caches the public URL. "" clears it.
func (s *Service) SetPublicURL(ctx context.Context, raw string) error {
normalized, err := NormalizePublicURL(raw)
if err != nil {
return err
}
if s == nil || s.pool == nil {
return errors.New("network settings unavailable")
}
row, err := dbq.New(s.pool).UpdatePublicURL(ctx, normalized)
if err != nil {
return err
}
s.mu.Lock()
s.publicURL = row.PublicUrl
s.mu.Unlock()
if s.logger != nil {
s.logger.Info("netsettings: public URL updated", "public_url", normalized)
}
return nil
}

Some files were not shown because too many files have changed in this diff Show More