M462 security hardening and M464 loudness analysis (steps 1–3) #135
@@ -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
@@ -72,9 +72,8 @@ name: release
|
||||
# Android APK and uploads it as a workflow artifact. The image-release
|
||||
# job declares `needs: android-release`, so the docker image cannot
|
||||
# start building until the APK is guaranteed-ready — no polling, no
|
||||
# race, no silent-failure mode. Asset attachment to the gitea Release
|
||||
# happens in the same android-release job, so the Release-page download
|
||||
# link and the in-image bundled APK are both populated atomically.
|
||||
# race, no silent-failure mode. Attaching the APK to the gitea Release is
|
||||
# its own job (release-assets), behind the test gate below.
|
||||
#
|
||||
# :latest always carries an APK. Because every main push also moves
|
||||
# :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
|
||||
# keep bundling their own freshly-built APK.
|
||||
#
|
||||
# Android testing (lint + detekt + unit tests, debug APK upload on main)
|
||||
# lives in android.yml and runs independently on every push.
|
||||
# THE GATE (rule 177, M462 #4984). Every verifying lane lives in this file —
|
||||
# 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:
|
||||
push:
|
||||
@@ -95,6 +119,11 @@ on:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
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
|
||||
# operator explicitly wants the later commit to win. Tags no longer enter
|
||||
@@ -105,6 +134,276 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
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:
|
||||
name: Build signed APK (releases and dev)
|
||||
# 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.
|
||||
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
|
||||
# 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
|
||||
# serves it from /api/client/apk like any other.
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
env:
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
VERSION_NAME: ${{ steps.ver.outputs.name }}
|
||||
VERSION_CODE: ${{ steps.ver.outputs.code }}
|
||||
VERSION_NAME: ${{ needs.android-release.outputs.version_name }}
|
||||
VERSION_CODE: ${{ needs.android-release.outputs.version_code }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
APK_PATH="app/build/outputs/apk/release/app-release.apk"
|
||||
APK_PATH="release-apk/app-release.apk"
|
||||
ls -lh "${APK_PATH}"
|
||||
|
||||
# Publish the version sidecar as a release asset next to the APK.
|
||||
@@ -313,13 +638,21 @@ jobs:
|
||||
|
||||
image-release:
|
||||
name: Build + push container image
|
||||
# `needs:` waits for android-release. For tag pushes android-release
|
||||
# runs and must succeed before this job starts — guaranteeing the
|
||||
# APK artifact is present. For main pushes android-release is
|
||||
# skipped; the `if: ...` below lets this job run anyway and the
|
||||
# download/copy steps gate themselves on the tag context.
|
||||
needs: [android-release]
|
||||
if: ${{ !failure() && !cancelled() }}
|
||||
# Every lane must have SUCCEEDED, each named here (rule 177). Then the
|
||||
# APK: tag and dev pushes build one and it must have succeeded; main
|
||||
# pushes skip android-release and bundle the latest release's APK
|
||||
# instead, so for main alone a skipped android-release is expected.
|
||||
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'
|
||||
|| (needs.android-release.result == 'skipped' && github.ref == 'refs/heads/main')) }}
|
||||
runs-on: go-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||
@@ -528,8 +861,13 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
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: |
|
||||
docker buildx build \
|
||||
docker buildx build --pull \
|
||||
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
|
||||
--build-arg MINSTREL_CHANNEL="${{ steps.tags.outputs.channel }}" \
|
||||
--push ${{ steps.tags.outputs.args }} .
|
||||
@@ -554,7 +892,7 @@ jobs:
|
||||
# above did NOT succeed.
|
||||
verify-release:
|
||||
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') }}
|
||||
runs-on: go-ci
|
||||
container:
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
name: test-go
|
||||
|
||||
# Go server: vet + golangci-lint + short race tests. Runs on push to
|
||||
# dev/main and PRs to main, scoped to Go-side files only — web-only or
|
||||
# Flutter-only diffs don't trigger this workflow.
|
||||
#
|
||||
# Two jobs: `test` (fast — vet + lint + `go test -short -race`, no DB) and
|
||||
# `integration` (full `go test -race` against an ephemeral Postgres).
|
||||
#
|
||||
# Integration-job DB wiring follows the act_runner shared-daemon pattern:
|
||||
# the runner's Docker daemon also runs the operator's dev compose stack,
|
||||
# so service containers get NO published ports (collision) and no
|
||||
# service-name DNS. We discover the service container by the job-scoped
|
||||
# name filter via the mounted docker socket and reach it by bridge IP.
|
||||
# The exactly-one assertion is a hard guard — pointing tests at the dev
|
||||
# Postgres would truncate it (the disaster Fable #339 exists to prevent).
|
||||
#
|
||||
# `web/build/` has a committed placeholder index.html so go:embed succeeds
|
||||
# without needing the SPA to be freshly built. Real builds happen in
|
||||
# release.yml (container) and locally during dev.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
paths:
|
||||
- '**/*.go'
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- 'sqlc.yaml'
|
||||
- 'Makefile'
|
||||
- 'internal/**'
|
||||
- 'cmd/**'
|
||||
- '.golangci.yml'
|
||||
- '.gitea/workflows/test-go.yml'
|
||||
# The release lane's own trigger is `main` + tags, so nothing it
|
||||
# contains is exercised until a release is already running. These two
|
||||
# entries are what let internal/server/release_version_test.go guard
|
||||
# the version derivation on ordinary dev pushes instead.
|
||||
- 'ci/**'
|
||||
- '.gitea/workflows/release.yml'
|
||||
|
||||
# pull_request trigger intentionally omitted — see test-web.yml for
|
||||
# the rationale (single-author repo, push covers PR-merge equivalent).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: go-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Toolchain versions
|
||||
run: |
|
||||
go version
|
||||
golangci-lint --version
|
||||
|
||||
- name: Generated code matches queries (sqlc)
|
||||
run: make verify-generate
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: golangci-lint
|
||||
run: golangci-lint run ./...
|
||||
|
||||
- name: go test (short, race)
|
||||
run: go test -short -race ./...
|
||||
|
||||
integration:
|
||||
runs-on: go-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: minstrel
|
||||
POSTGRES_PASSWORD: minstrel
|
||||
POSTGRES_DB: minstrel_test
|
||||
# No `ports:` — the runner shares the operator's dev compose
|
||||
# Docker daemon; publishing a fixed host port collides.
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Integration suite (discover service by bridge IP, migrate, test)
|
||||
run: |
|
||||
set -eux
|
||||
# Discover THIS job's Postgres service container via the
|
||||
# mounted docker socket. act_runner attaches the job
|
||||
# container and its service container(s) to a shared per-job
|
||||
# network, so scope discovery to a postgres that sits on a
|
||||
# network THIS job container is also on. The old
|
||||
# `--filter name=integration` matched EVERY concurrent
|
||||
# integration run's postgres (a dev push + the main-merge run
|
||||
# overlap → 2 candidates → false "expected exactly 1" abort).
|
||||
# The operator's dev compose `minstrel-postgres-*` is never on
|
||||
# this job's network; skip it explicitly as belt-and-suspenders
|
||||
# (a wrong target would truncate real data).
|
||||
SELF=$(cat /etc/hostname)
|
||||
SELF_NETS=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$SELF")
|
||||
test -n "$SELF_NETS"
|
||||
echo "self ($SELF) networks: $SELF_NETS"
|
||||
PG_ID=""
|
||||
PG_NAME=""
|
||||
for cid in $(docker ps --filter "ancestor=postgres:16-alpine" -q); do
|
||||
nm=$(docker inspect -f '{{.Name}}' "$cid" | sed 's#^/##')
|
||||
case "$nm" in *minstrel-postgres*|*_postgres_*) continue ;; esac
|
||||
for net in $(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$cid"); do
|
||||
case " $SELF_NETS " in *" $net "*) PG_ID="$cid"; PG_NAME="$nm"; break 2 ;; esac
|
||||
done
|
||||
done
|
||||
test -n "$PG_ID" || { echo "FATAL: no postgres service container on this job's network (self nets: $SELF_NETS)"; exit 1; }
|
||||
echo "selected postgres: $PG_ID $PG_NAME"
|
||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG_ID")
|
||||
test -n "$PG_IP"
|
||||
export MINSTREL_TEST_DATABASE_URL="postgres://minstrel:minstrel@${PG_IP}:5432/minstrel_test?sslmode=disable"
|
||||
|
||||
# Wait for Postgres to accept TCP (no health-check dependency).
|
||||
for i in $(seq 1 60); do (echo > "/dev/tcp/${PG_IP}/5432") 2>/dev/null && break; sleep 2; done
|
||||
|
||||
# Relax durability on the throwaway CI Postgres. Our test pattern
|
||||
# is dbtest.ResetDB → TRUNCATE … RESTART IDENTITY CASCADE before
|
||||
# every test, and the per-TRUNCATE commit fsync is the dominant
|
||||
# cost of the integration suite. The CI DB is rebuilt every run so
|
||||
# fsync / full_page_writes / synchronous_commit buy nothing. Apply
|
||||
# via docker exec because:
|
||||
# - The act_runner `services:` block can't override the container
|
||||
# command, so `postgres -c fsync=off` at boot isn't an option.
|
||||
# - ALTER SYSTEM cannot run inside a transaction; psql -c
|
||||
# auto-commits each statement, which is what we need.
|
||||
# - fsync / full_page_writes are sighup GUCs and
|
||||
# synchronous_commit is user-context, so pg_reload_conf() picks
|
||||
# all three up with no restart.
|
||||
# Non-fatal: a perms surprise degrades to "slower", never red CI.
|
||||
docker exec "$PG_ID" psql -U minstrel -d minstrel_test \
|
||||
-c "ALTER SYSTEM SET fsync = off" \
|
||||
-c "ALTER SYSTEM SET synchronous_commit = off" \
|
||||
-c "ALTER SYSTEM SET full_page_writes = off" \
|
||||
-c "SELECT pg_reload_conf()" \
|
||||
|| echo "WARN: durability relax failed; continuing"
|
||||
|
||||
# Apply embedded migrations to the fresh test DB, then run the
|
||||
# full suite (no -short → integration tests execute). -p 1:
|
||||
# every integration package TRUNCATEs the one shared test DB;
|
||||
# concurrent package binaries → TRUNCATE deadlocks. Serialize
|
||||
# package execution (the documented local invocation too).
|
||||
MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate
|
||||
go test -p 1 -race ./...
|
||||
@@ -1,44 +0,0 @@
|
||||
name: test-web
|
||||
|
||||
# Web SPA: vitest + svelte-check. Runs on push to dev/main only —
|
||||
# the `pull_request` trigger is intentionally omitted because every
|
||||
# branch on this repo is local-only (no fork PRs), so the dev push
|
||||
# fully covers what a PR run would re-execute. Keeping both events
|
||||
# doubled CI cost on every commit.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
paths:
|
||||
- 'web/**'
|
||||
- '.gitea/workflows/test-web.yml'
|
||||
|
||||
# Cancel an earlier in-flight run for the same ref when a newer
|
||||
# commit arrives. With cancel-in-progress, rapid re-pushes don't
|
||||
# pile up zombie runs.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: go-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Type-check + svelte-check
|
||||
run: npm run check
|
||||
|
||||
- name: Vitest
|
||||
run: npm test
|
||||
+1
-1
@@ -7,7 +7,7 @@ RUN npm ci
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM golang:1.25-bookworm AS builder
|
||||
FROM golang:1.26-bookworm AS builder
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
@@ -34,6 +34,9 @@ Minstrel is not affiliated with or endorsed by Lidarr, ListenBrainz, MusicBrainz
|
||||
services:
|
||||
minstrel:
|
||||
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']
|
||||
volumes:
|
||||
# Your music library. Point ./music at wherever your audio files
|
||||
@@ -76,9 +79,9 @@ docker compose up -d
|
||||
|
||||
## 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>
|
||||
|
||||
@@ -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).
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ object ErrorCopy {
|
||||
"forbidden" 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.",
|
||||
"rate_limited" to "Too many attempts. Wait a few minutes and try again.",
|
||||
"wrong_password" to "Current password is incorrect.",
|
||||
"password_too_short" to "Password must be at least 8 characters.",
|
||||
"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.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -27,6 +33,14 @@ import javax.inject.Singleton
|
||||
* in-memory state changes synchronously so the next interceptor read
|
||||
* sees the new value immediately; the DAO write coroutine catches up
|
||||
* 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
|
||||
// app_preferences — see entity comment). It legitimately owns one
|
||||
@@ -39,6 +53,7 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class AuthStore @Inject constructor(
|
||||
private val dao: AuthSessionDao,
|
||||
private val vault: SessionVault,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
) {
|
||||
private val sessionCookieState = MutableStateFlow<String?>(null)
|
||||
@@ -64,10 +79,20 @@ class AuthStore @Inject constructor(
|
||||
|
||||
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 {
|
||||
scope.launch {
|
||||
dao.observe().collect { row ->
|
||||
sessionCookieState.value = row?.sessionCookie
|
||||
baseUrlState.value = row?.baseUrl ?: DEFAULT_BASE_URL
|
||||
userJsonState.value = row?.userJson
|
||||
themeModeState.value = row?.themeMode
|
||||
@@ -85,9 +110,50 @@ class AuthStore @Inject constructor(
|
||||
}.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?) {
|
||||
cookieTouched = true
|
||||
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) {
|
||||
@@ -121,7 +187,7 @@ class AuthStore @Inject constructor(
|
||||
scope.launch { persistDiagnosticsOptOut(value) }
|
||||
}
|
||||
|
||||
private suspend fun persistCookie(value: String?) {
|
||||
private suspend fun persistLegacyCookie(value: String?) {
|
||||
if (dao.get() == null) {
|
||||
dao.upsert(currentEntity().copy(sessionCookie = value))
|
||||
} else {
|
||||
@@ -179,7 +245,9 @@ class AuthStore @Inject constructor(
|
||||
|
||||
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
|
||||
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,
|
||||
userJson = userJsonState.value,
|
||||
themeMode = themeModeState.value,
|
||||
@@ -193,6 +261,10 @@ class AuthStore @Inject constructor(
|
||||
|
||||
companion object {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* persisted auth state. Sits on top of AuthSessionDao directly rather
|
||||
* than AuthStore's StateFlow because the StateFlow defaults to null
|
||||
* until Room's first async emission — we need a definitive answer
|
||||
* before drawing any nav graph.
|
||||
* persisted auth state. Reads the row from AuthSessionDao directly, and
|
||||
* the session cookie only after [AuthStore.awaitSessionHydrated]: both
|
||||
* StateFlows default to null until their async load lands, and we need a
|
||||
* 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)
|
||||
* - row with baseUrl, no cookie → Login (URL configured, not yet signed in)
|
||||
@@ -31,6 +32,7 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class AuthGateViewModel @Inject constructor(
|
||||
private val dao: AuthSessionDao,
|
||||
private val authStore: AuthStore,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow<Any?>(null)
|
||||
@@ -38,12 +40,13 @@ class AuthGateViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
authStore.awaitSessionHydrated()
|
||||
val signedIn = !authStore.sessionCookie.value.isNullOrEmpty()
|
||||
val row = dao.get()
|
||||
internal.value = when {
|
||||
row == null -> ServerUrl
|
||||
row.baseUrl == AuthStore.DEFAULT_BASE_URL && row.sessionCookie.isNullOrEmpty() ->
|
||||
ServerUrl
|
||||
row.sessionCookie.isNullOrEmpty() -> Login
|
||||
row.baseUrl == AuthStore.DEFAULT_BASE_URL && !signedIn -> ServerUrl
|
||||
!signedIn -> Login
|
||||
else -> Home
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ class PasswordViewModel @Inject constructor(
|
||||
try {
|
||||
repository.changePassword(current = s.current, next = s.next)
|
||||
internal.update {
|
||||
PasswordUiState(message = "Password changed.")
|
||||
PasswordUiState(message = "Password changed. Your other devices have been signed out.")
|
||||
}
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.fabledsword.minstrel.api
|
||||
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.auth.FakeSessionVault
|
||||
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
@@ -43,7 +44,7 @@ class AuthCookieInterceptorTest {
|
||||
coEvery { setSessionCookie(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.
|
||||
// AuthCookieInterceptor scopes its attach + clear behavior to
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.fabledsword.minstrel.api
|
||||
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.auth.FakeSessionVault
|
||||
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
@@ -38,7 +39,7 @@ class BaseUrlInterceptorTest {
|
||||
coEvery { setSessionCookie(any()) } returns Unit
|
||||
coEvery { setBaseUrl(any()) } returns Unit
|
||||
}
|
||||
authStore = AuthStore(dao, TestScope(UnconfinedTestDispatcher()))
|
||||
authStore = AuthStore(dao, FakeSessionVault(), TestScope(UnconfinedTestDispatcher()))
|
||||
}
|
||||
|
||||
@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
@@ -12,8 +12,9 @@ git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||
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-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).
|
||||
- `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).
|
||||
- `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.**
|
||||
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`
|
||||
- **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`.
|
||||
- **golangci-lint** — lint pass in `test-go.yml`.
|
||||
- **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`).
|
||||
- **Node + npm** — `npm ci`, `npm audit`, `npm test` and `npm run check` in the `web` lane.
|
||||
- **golangci-lint** — lint pass in the `go` lane.
|
||||
- **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`.
|
||||
- **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
|
||||
`ci-flutter`).
|
||||
- **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.
|
||||
- **git** — `actions/checkout@v4` baseline (and any shell git operations).
|
||||
- **base64 + curl** — keystore decode + release-asset upload in `release.yml`'s
|
||||
@@ -58,10 +59,10 @@ None.
|
||||
## 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.
|
||||
- **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.
|
||||
- **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`.**
|
||||
```yaml
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
+8
-13
@@ -55,12 +55,14 @@ func runAdmin(args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// adminResetPassword resets a user's credentials. It updates BOTH
|
||||
// password_hash (bcrypt, for /api/auth/login) and subsonic_password
|
||||
// (plaintext, required for Subsonic t+s token verification) so neither
|
||||
// auth path is left stale. Recovers a locked-out operator when the
|
||||
// bootstrap password was missed or the DB volume was recreated (Fable
|
||||
// #321) without DB surgery.
|
||||
// adminResetPassword resets a user's login password (password_hash). It
|
||||
// recovers a locked-out operator when the bootstrap password was missed or
|
||||
// the DB volume was recreated (Fable #321) without DB surgery.
|
||||
//
|
||||
// It deliberately leaves subsonic_password alone. That column is stored in
|
||||
// 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 {
|
||||
fs := flag.NewFlagSet("admin reset-password", flag.ContinueOnError)
|
||||
configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
|
||||
@@ -112,13 +114,6 @@ func adminResetPassword(args []string) error {
|
||||
}); err != nil {
|
||||
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 {
|
||||
fmt.Printf("minstrel: password for %q reset.\nNew password: %s\n", *username, pw)
|
||||
|
||||
@@ -132,6 +132,13 @@ func run() error {
|
||||
}
|
||||
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
|
||||
if contact == "" {
|
||||
contact = "https://git.fabledsword.com/bvandeusen/minstrel"
|
||||
@@ -228,6 +235,11 @@ func run() error {
|
||||
// internal/library/fingerprint_backfill.go for why.
|
||||
go library.NewFingerprintBackfillWorker(pool, logger.With("component", "fingerprint_backfill"), fpSettings).Run(ctx)
|
||||
|
||||
// Loudness backfill (M464 #4995): measures every track's loudness for
|
||||
// normalization, new tracks included; the scan only drops a changed file's
|
||||
// measurement. See internal/library/loudness_backfill.go.
|
||||
go library.NewLoudnessBackfillWorker(pool, logger.With("component", "loudness_backfill"), loudSettings).Run(ctx)
|
||||
|
||||
// Duplicate sweep (M400 #3910): proposes groups of tracks holding one
|
||||
// recording, from the fingerprints above. Sweeps only when fingerprints have
|
||||
// changed since the last sweep.
|
||||
@@ -377,6 +389,7 @@ func run() error {
|
||||
srv.RecSettings = recSettings
|
||||
srv.TagSettings = tagSettings
|
||||
srv.FingerprintSettings = fpSettings
|
||||
srv.LoudnessSettings = loudSettings
|
||||
// The sweeper above holds this same instance, so a save from the admin
|
||||
// card changes what it does on its next tick (#3936).
|
||||
srv.ReacqSettings = reacqSettings
|
||||
@@ -385,6 +398,11 @@ func run() error {
|
||||
Addr: cfg.Server.Address,
|
||||
Handler: srv.Router(),
|
||||
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)
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ version: "3.9"
|
||||
# tests at it:
|
||||
# make test-integration
|
||||
# (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):
|
||||
# docker compose up --build
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Hosting Minstrel
|
||||
|
||||
Minstrel serves plain HTTP on port 4533 and never terminates TLS itself. On a
|
||||
home network or a VPN you trust, that is all you need. Once the server is
|
||||
reachable from the internet, put it behind a reverse proxy that speaks HTTPS,
|
||||
and tell Minstrel about that proxy. This page covers both.
|
||||
|
||||
## On a LAN or VPN
|
||||
|
||||
Publish the port to the network and use `http://<host>:4533`:
|
||||
|
||||
```yaml
|
||||
ports: ['4533:4533']
|
||||
```
|
||||
|
||||
If nothing sits in front of Minstrel, set **Admin → Integrations → Client IP
|
||||
detection** to 0. It defaults to 1, which assumes one proxy (see below for
|
||||
why that matters).
|
||||
|
||||
## On the internet
|
||||
|
||||
Three things, all required:
|
||||
|
||||
1. **Don't publish 4533 to the world.** Bind it to the loopback interface so
|
||||
only the proxy on the same host can reach it:
|
||||
|
||||
```yaml
|
||||
ports: ['127.0.0.1:4533:4533']
|
||||
```
|
||||
|
||||
If the proxy runs in another container on the same Docker network, drop
|
||||
`ports:` entirely and point the proxy at `minstrel:4533`.
|
||||
|
||||
2. **Terminate HTTPS at a reverse proxy.** Any proxy works. With Caddy, which
|
||||
gets and renews certificates by itself:
|
||||
|
||||
```
|
||||
music.example.com {
|
||||
reverse_proxy 127.0.0.1:4533
|
||||
}
|
||||
```
|
||||
|
||||
Two settings matter for every proxy:
|
||||
- **Don't buffer responses.** Live updates use Server-Sent Events and audio
|
||||
is streamed; a buffering proxy holds both back. Caddy streams by default.
|
||||
For nginx, set `proxy_buffering off;`.
|
||||
- **Allow long-lived responses.** An event stream stays open for as long as
|
||||
the app is. Raise the proxy's read timeout well past a minute (nginx:
|
||||
`proxy_read_timeout 1h;`).
|
||||
|
||||
3. **Tell Minstrel where it lives**, in **Admin → Integrations**:
|
||||
- **Client IP detection:** the number of proxies in front of Minstrel. One
|
||||
proxy (Caddy, Traefik, nginx) is 1; Cloudflare in front of Traefik is 2.
|
||||
Minstrel uses this to find the real client address, for login rate
|
||||
limits and the sessions list, and to tell whether a request arrived over
|
||||
HTTPS.
|
||||
- **Public address:** the URL people use, e.g. `https://music.example.com`.
|
||||
Password-reset emails link here. **Until it is set, no reset email is
|
||||
sent**, so a forged `Host` header can never put a reset token on a link
|
||||
to someone else's server.
|
||||
|
||||
### Why the proxy count matters
|
||||
|
||||
Minstrel only believes `X-Forwarded-For` and `X-Forwarded-Proto` from the
|
||||
number of proxies you configure, counted from the right. Set it too high, or
|
||||
leave it at the default of 1 with no proxy in front, and a client can write
|
||||
those headers itself: it could claim any address to slip past the per-address
|
||||
login limit, or claim HTTPS. With no proxy, set it to 0.
|
||||
|
||||
When the proxy reports HTTPS, Minstrel marks the session cookie `Secure` and
|
||||
sends `Strict-Transport-Security`. Over plain HTTP it does neither, and it
|
||||
never redirects to HTTPS, so LAN use keeps working unchanged.
|
||||
|
||||
## First account
|
||||
|
||||
A fresh server with no accounts prints a **setup token** in its log:
|
||||
|
||||
```
|
||||
docker compose logs minstrel | grep setup_token
|
||||
```
|
||||
|
||||
Creating the first account (which becomes the admin) requires that token, so
|
||||
whoever reaches a newly exposed server first can't claim it. The token
|
||||
changes on every restart until an account exists.
|
||||
|
||||
## Android app
|
||||
|
||||
The app connects over whatever URL you give it, HTTP or HTTPS. Once the
|
||||
server has a public HTTPS address, give the app that address so the session
|
||||
cookie never crosses the internet unencrypted. See
|
||||
[security notes](./security.md#android-allows-plain-http) for why plain HTTP
|
||||
is still allowed.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Security notes
|
||||
|
||||
What Minstrel does to protect accounts, and the reasoning behind the
|
||||
decisions that look odd at first. For deployment steps, see
|
||||
[hosting](./hosting.md).
|
||||
|
||||
## Accounts and sessions
|
||||
|
||||
- **Passwords** are stored as bcrypt hashes.
|
||||
- **Login, registration and password reset are rate-limited:** 10 failed
|
||||
sign-ins per account and 50 per address per 15 minutes, with similar limits
|
||||
on register, forgot-password and reset. The Subsonic `/rest` API shares the
|
||||
login limits. An unknown username takes as long to reject as a wrong
|
||||
password, so timing doesn't reveal which accounts exist.
|
||||
- **Sessions** are random 256-bit tokens; the server stores only their
|
||||
SHA-256. A session ends after 30 days unused or 365 days in total. Changing
|
||||
your password signs out your other devices; a password reset, or an admin
|
||||
resetting it, signs out all of them.
|
||||
- **API keys** (the OpenSubsonic `apiKey`) are stored as SHA-256 too, so a
|
||||
key is shown once, when you generate it in Settings, and can only be
|
||||
replaced after that.
|
||||
- **The first account** on an empty server needs the setup token from the
|
||||
server log (see [hosting](./hosting.md#first-account)).
|
||||
- **Password-reset links** are built only from the configured public address,
|
||||
never from the request's `Host` header.
|
||||
|
||||
## Requests
|
||||
|
||||
- Request bodies are capped at 4 MiB and must arrive within 30 seconds.
|
||||
Streams and the live-event connection are unaffected.
|
||||
- Every response carries `X-Content-Type-Options: nosniff`,
|
||||
`Referrer-Policy: strict-origin-when-cross-origin`, a restrictive
|
||||
`Permissions-Policy` and `X-Frame-Options: DENY`. The web app also gets a
|
||||
`Content-Security-Policy` that allows only its own scripts, by hash.
|
||||
- Media responses are `Cache-Control: private`, so a shared cache never keeps
|
||||
one user's audio or artwork for another.
|
||||
|
||||
## CSRF: SameSite cookies plus JSON-only writes
|
||||
|
||||
There are no CSRF tokens. The session cookie is `SameSite=Strict`, so
|
||||
browsers don't send it with requests that start on another site. That leaves
|
||||
one gap: SameSite treats every subdomain of the same registrable domain as the
|
||||
same site, so a different app on `other.example.com` could still send a
|
||||
request carrying the cookie. To close it, any state-changing `/api` request
|
||||
authenticated by the cookie must have a JSON body (`Content-Type:
|
||||
application/json`) or no body at all. An HTML form or a script on another
|
||||
origin can't send JSON without a CORS preflight, and Minstrel answers no
|
||||
cross-origin preflight. Requests authenticated with a bearer token or the
|
||||
Subsonic query parameters carry nothing a browser attaches on its own, so
|
||||
they aren't checked.
|
||||
|
||||
## Subsonic sign-in, and the one password stored in plain text
|
||||
|
||||
Classic Subsonic clients sign in with `t` and `s`: the MD5 of the password
|
||||
followed by a random salt. To check that, the server has to know the password
|
||||
itself, so supporting this sign-in method means storing a password Minstrel
|
||||
can read. That is the Subsonic password, and it is the only credential
|
||||
Minstrel keeps unhashed.
|
||||
|
||||
- **The recommended way in is the API key.** Clients that support the
|
||||
OpenSubsonic `apiKey` should use it. The key is stored hashed and can be
|
||||
replaced at any time in Settings.
|
||||
- **The Subsonic password is never your login password.** Minstrel generates
|
||||
it (**Settings → Subsonic password**), shows it once, and lets you replace
|
||||
or turn it off. Because it is random, a copy of the database exposes access
|
||||
to this server's Subsonic API and nothing else: it can't be a password you
|
||||
also use somewhere else.
|
||||
- **`t`/`s` and `p=` sign-in are off for an account until it has a Subsonic
|
||||
password.** Plain `p=` sign-in is additionally off server-wide unless
|
||||
`subsonic.allow_plaintext_password` is enabled.
|
||||
- `minstrel admin reset-password` changes only the login password. Older
|
||||
versions also copied it into the Subsonic password; upgrading clears every
|
||||
Subsonic password once, so those copies are gone. An account that used
|
||||
`t`/`s` sign-in needs a new Subsonic password generated in Settings.
|
||||
|
||||
## Android allows plain HTTP
|
||||
|
||||
The Android app permits cleartext connections, for two reasons that can't be
|
||||
narrowed to a list of hosts. The server address is whatever the user types,
|
||||
and many self-hosted servers run plain HTTP on a LAN. And UPnP, DLNA and
|
||||
Sonos speakers are controlled over plain HTTP at addresses that are only
|
||||
known once they're discovered. The reasoning lives in
|
||||
`android/app/src/main/res/xml/network_security_config.xml`.
|
||||
|
||||
This doesn't weaken app updates: an APK altered in transit fails the
|
||||
platform's signature check. It does mean a server reached over plain HTTP
|
||||
across the internet exposes the session cookie in transit, which is why the
|
||||
[hosting guide](./hosting.md) puts public servers behind HTTPS.
|
||||
|
||||
On the phone, the session cookie is encrypted with a key held in the Android
|
||||
Keystore, so a copy of the app's files doesn't yield a usable session.
|
||||
|
||||
## Build pipeline
|
||||
|
||||
Nothing is published unless every check passes: the Go, integration, web and
|
||||
Android test suites, `govulncheck` against the toolchain that builds the
|
||||
image, and `npm audit` on the packages that ship to the browser. See
|
||||
`.gitea/workflows/release.yml`.
|
||||
|
||||
## Reporting a problem
|
||||
|
||||
Open an issue on the
|
||||
[repository](https://git.fabledsword.com/bvandeusen/minstrel/issues), or
|
||||
contact the maintainer privately first if it's something that shouldn't be
|
||||
public until fixed.
|
||||
@@ -25,7 +25,7 @@ require (
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.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/text v0.37.0 // indirect
|
||||
golang.org/x/text v0.39.0 // indirect
|
||||
)
|
||||
|
||||
@@ -95,12 +95,12 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
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.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
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/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
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 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -23,10 +23,16 @@ type networkSettingsResp struct {
|
||||
// arrived and count them rather than guess.
|
||||
ForwardedChain string `json:"forwarded_chain"`
|
||||
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 {
|
||||
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) {
|
||||
@@ -39,13 +45,37 @@ func (h *handlers) handleUpdateNetworkSettings(w http.ResponseWriter, r *http.Re
|
||||
writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON"))
|
||||
return
|
||||
}
|
||||
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()))
|
||||
if req.TrustedProxyHops == nil && req.PublicURL == nil {
|
||||
writeErr(w, apierror.BadRequest("invalid_body", "nothing to update"))
|
||||
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
|
||||
}
|
||||
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
|
||||
// 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),
|
||||
ForwardedChain: r.Header.Get("X-Forwarded-For"),
|
||||
RemoteAddr: r.RemoteAddr,
|
||||
PublicURL: h.netSettings.PublicURL(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ func (h *handlers) handleAdminCreateUser(w http.ResponseWriter, r *http.Request)
|
||||
user, err := q.CreateUserAdmin(r.Context(), dbq.CreateUserAdminParams{
|
||||
Username: req.Username,
|
||||
PasswordHash: string(hash),
|
||||
ApiToken: apiToken,
|
||||
ApiTokenHash: auth.HashAPIToken(apiToken),
|
||||
IsAdmin: req.IsAdmin,
|
||||
DisplayName: req.DisplayName,
|
||||
})
|
||||
@@ -285,6 +285,19 @@ func (h *handlers) handleAdminResetPassword(w http.ResponseWriter, r *http.Reque
|
||||
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)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
+44
-2
@@ -7,6 +7,7 @@ package api
|
||||
import (
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -33,8 +34,15 @@ import (
|
||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||
// RequireUser; everything else is gated by the middleware. The events writer
|
||||
// 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()))
|
||||
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{
|
||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||
recSettings: recSettings,
|
||||
@@ -57,12 +65,21 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
netSettings: netSettings,
|
||||
reacqSettings: reacqSettings,
|
||||
fingerprintSettings: fpSettings,
|
||||
loudnessSettings: loudSettings,
|
||||
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) {
|
||||
api.Post("/auth/login", h.handleLogin)
|
||||
api.Post("/auth/register", h.handleRegister)
|
||||
api.Get("/auth/setup-status", h.handleSetupStatus)
|
||||
api.Post("/auth/forgot-password", h.handleForgotPassword)
|
||||
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/profile", h.handleUpdateMyProfile)
|
||||
authed.Put("/me/timezone", h.handlePutTimezone)
|
||||
authed.Get("/me/api-token", h.handleGetMyAPIToken)
|
||||
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.Delete("/me/sessions/{id}", h.handleRevokeMySession)
|
||||
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/years", h.handleListAlbumYears)
|
||||
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)
|
||||
// /tracks/{id}/stream is mounted above with OptionalUser so
|
||||
// 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/fingerprint-settings", h.handleGetFingerprintSettings)
|
||||
admin.Put("/library/fingerprint-settings", h.handleUpdateFingerprintSettings)
|
||||
admin.Get("/library/loudness", h.handleGetLoudnessCoverage)
|
||||
admin.Get("/library/loudness-settings", h.handleGetLoudnessSettings)
|
||||
admin.Put("/library/loudness-settings", h.handleUpdateLoudnessSettings)
|
||||
// Duplicates report (#3912): proposals from the duplicate sweep, a
|
||||
// trigger to sweep now, dismissal, and the merge (#3911), which deletes
|
||||
// the removed copies' files after moving their history onto the kept one.
|
||||
@@ -313,6 +338,23 @@ type handlers struct {
|
||||
// instance the scanner and the fingerprint workers read, so a save from the
|
||||
// admin card reaches them without a restart. Nil serves the defaults.
|
||||
fingerprintSettings *library.FingerprintSettingsService
|
||||
// loudnessSettings is the loudness analysis policy (M464 #4995), the same
|
||||
// instance the loudness backfill reads. Nil serves the defaults.
|
||||
loudnessSettings *library.LoudnessSettingsService
|
||||
// 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
|
||||
// middleware on every request and edited from the admin network card.
|
||||
netSettings *netsettings.Service
|
||||
|
||||
+30
-2
@@ -19,6 +19,20 @@ import (
|
||||
// so an abandoned laptop doesn't stay logged in forever.
|
||||
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) {
|
||||
// The session token can be on the cookie OR bearer header — RequireUser
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
user, err := q.GetUserByUsername(r.Context(), req.Username)
|
||||
if err != nil {
|
||||
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"))
|
||||
return
|
||||
}
|
||||
@@ -82,9 +108,11 @@ func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !auth.VerifyPassword(user.PasswordHash, req.Password) {
|
||||
h.loginGuard.Fail(req.Username, addr)
|
||||
writeErr(w, apierror.Unauthorized("invalid_credentials", "invalid username or password"))
|
||||
return
|
||||
}
|
||||
h.loginGuard.Succeed(req.Username)
|
||||
|
||||
token, err := auth.MintSessionToken()
|
||||
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
|
||||
// user recognises but is being used from somewhere they don't is the
|
||||
// case this whole surface exists to surface.
|
||||
Ip: auth.ClientIP(r, h.netSettings.Hops()),
|
||||
Ip: addr,
|
||||
}); err != nil {
|
||||
h.logger.Error("api: insert session failed", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("insert failed", err))
|
||||
@@ -112,7 +140,7 @@ func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
Value: token,
|
||||
Path: "/",
|
||||
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,
|
||||
MaxAge: int(sessionCookieMaxAge.Seconds()),
|
||||
})
|
||||
|
||||
+33
-12
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"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/mailer"
|
||||
)
|
||||
@@ -47,6 +48,19 @@ func (h *handlers) handleForgotPassword(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
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)
|
||||
var matched bool
|
||||
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 != "" {
|
||||
matched = true
|
||||
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",
|
||||
"email", email, "err", sendErr)
|
||||
// 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
|
||||
// email. Returns the underlying error (caller logs it but doesn't
|
||||
// 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 {
|
||||
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
|
||||
}
|
||||
|
||||
resetURL := buildResetURL(r, token)
|
||||
resetURL, err := buildResetURL(h.netSettings.PublicURL(), token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
textBody, htmlBody, err := mailer.RenderResetEmail(mailer.ResetEmailVars{
|
||||
Username: user.Username,
|
||||
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)
|
||||
}
|
||||
|
||||
func buildResetURL(r *http.Request, token string) string {
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
// errNoPublicURL stops a reset email from going out before the operator has
|
||||
// said where Minstrel lives. It surfaces in the log, not the response, which
|
||||
// stays the same opaque 200 either way.
|
||||
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
|
||||
if host == "" {
|
||||
host = "localhost"
|
||||
}
|
||||
return scheme + "://" + host + "/reset-password/" + token
|
||||
return publicURL + "/reset-password/" + token, nil
|
||||
}
|
||||
|
||||
@@ -7,11 +7,59 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"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) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
@@ -19,6 +67,7 @@ func TestForgotPassword_KnownEmail_SendsMail(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
fake := &mailer.FakeSender{}
|
||||
h.mailer = fake
|
||||
withPublicURL(t, h, pool, "https://music.example.com")
|
||||
|
||||
user := seedUser(t, pool, "forgotuser", "pw", false)
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
@@ -29,7 +78,8 @@ func TestForgotPassword_KnownEmail_SendsMail(t *testing.T) {
|
||||
body := `{"email":"forgot@example.com"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password",
|
||||
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()
|
||||
h.handleForgotPassword(rec, req)
|
||||
|
||||
@@ -43,6 +93,12 @@ func TestForgotPassword_KnownEmail_SendsMail(t *testing.T) {
|
||||
if got.To != "forgot@example.com" {
|
||||
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.
|
||||
var tokenCount int
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
@@ -10,6 +12,7 @@ import (
|
||||
"github.com/jackc/pgerrcode"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
@@ -26,9 +29,12 @@ var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{3,32}$`)
|
||||
const minPasswordLength = 8
|
||||
|
||||
type registerReq struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
InviteToken string `json:"invite_token"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -55,6 +61,15 @@ type registerReq struct {
|
||||
// - 409 username_taken (PG unique violation on users.username)
|
||||
// - 500 server_error otherwise
|
||||
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
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, apierror.BadRequest("invalid_body", "invalid JSON body"))
|
||||
@@ -79,6 +94,19 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
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).
|
||||
usedInviteToken := ""
|
||||
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{
|
||||
Username: req.Username,
|
||||
PasswordHash: string(hash),
|
||||
ApiToken: apiToken,
|
||||
ApiTokenHash: auth.HashAPIToken(apiToken),
|
||||
DisplayName: req.DisplayName,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -175,7 +203,7 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
UserID: user.ID,
|
||||
TokenHash: auth.HashSessionToken(sessionToken),
|
||||
UserAgent: r.UserAgent(),
|
||||
Ip: auth.ClientIP(r, h.netSettings.Hops()),
|
||||
Ip: addr,
|
||||
}); err != nil {
|
||||
h.logger.Error("register: insert session failed", "err", err)
|
||||
writeErr(w, apierror.Internal(err))
|
||||
@@ -186,7 +214,7 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
Value: sessionToken,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil,
|
||||
Secure: auth.IsHTTPS(r, h.netSettings.Hops()), // plain HTTP and LAN stay functional (rule 94)
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: int(sessionCookieMaxAge.Seconds()),
|
||||
})
|
||||
@@ -223,3 +251,31 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handleSetupStatus implements GET /api/auth/setup-status. It tells the
|
||||
// register screen whether to ask for the setup token, i.e. whether no
|
||||
// account exists yet. Public, since it is needed before anyone can sign in;
|
||||
// "this server has no users" is not worth hiding from someone who could
|
||||
// simply try to register.
|
||||
func (h *handlers) handleSetupStatus(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := dbq.New(h.pool).CountUsers(r.Context())
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "setup status: count users failed", apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"setup_required": n == 0})
|
||||
}
|
||||
|
||||
// logSetupTokenIfNeeded writes the setup token to the log at boot when the
|
||||
// instance has no accounts yet, with the instruction for using it.
|
||||
func logSetupTokenIfNeeded(pool *pgxpool.Pool, logger *slog.Logger, token *auth.SetupToken) {
|
||||
if pool == nil || logger == nil {
|
||||
return
|
||||
}
|
||||
n, err := dbq.New(pool).CountUsers(context.Background())
|
||||
if err != nil || n > 0 {
|
||||
return
|
||||
}
|
||||
logger.Warn("no accounts yet: open the web app, choose Create account, and enter this setup token to become the admin",
|
||||
"setup_token", token.Value())
|
||||
}
|
||||
|
||||
@@ -273,3 +273,75 @@ func TestRegister_FirstAdminRace(t *testing.T) {
|
||||
// not "exactly one" (which would require serializable isolation).
|
||||
t.Logf("admin count after race = %d (>=1 is the invariant)", adminCount)
|
||||
}
|
||||
|
||||
// With the gate on (as Mount sets it), the first account needs the setup
|
||||
// token from the log; a missing or wrong one is refused and creates nothing.
|
||||
func TestRegister_FirstUserNeedsSetupToken(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, _ := testHandlers(t)
|
||||
resetUsers(t, h)
|
||||
token, err := auth.NewSetupToken()
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
h.setupToken = token
|
||||
h.requireSetupToken = true
|
||||
|
||||
register := func(body string) int {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader([]byte(body)))
|
||||
rec := httptest.NewRecorder()
|
||||
h.handleRegister(rec, req)
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
if code := register(`{"username":"squatter","password":"abcd1234"}`); code != http.StatusForbidden {
|
||||
t.Errorf("no token: status = %d, want 403", code)
|
||||
}
|
||||
if code := register(`{"username":"squatter","password":"abcd1234","setup_token":"wrong"}`); code != http.StatusForbidden {
|
||||
t.Errorf("wrong token: status = %d, want 403", code)
|
||||
}
|
||||
var n int
|
||||
if err := h.pool.QueryRow(context.Background(), `SELECT count(*) FROM users`).Scan(&n); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("a refused registration created %d account(s)", n)
|
||||
}
|
||||
|
||||
if code := register(`{"username":"operator","password":"abcd1234","setup_token":"` + token.Value() + `"}`); code != http.StatusOK {
|
||||
t.Fatalf("right token: status = %d, want 200", code)
|
||||
}
|
||||
|
||||
// Once an account exists the token plays no part: the second user is
|
||||
// governed by registration mode, not the setup token.
|
||||
if _, err := h.pool.Exec(context.Background(),
|
||||
`UPDATE registration_settings SET mode = 'open' WHERE id = true`); err != nil {
|
||||
t.Fatalf("open mode: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = h.pool.Exec(context.Background(),
|
||||
`UPDATE registration_settings SET mode = 'invite_only' WHERE id = true`)
|
||||
})
|
||||
if code := register(`{"username":"second","password":"abcd1234"}`); code != http.StatusOK {
|
||||
t.Errorf("second user without token: status = %d, want 200", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupToken_MatchesOnlyItself(t *testing.T) {
|
||||
tok, err := auth.NewSetupToken()
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
if !tok.Matches(tok.Value()) {
|
||||
t.Error("token does not match itself")
|
||||
}
|
||||
if tok.Matches("") || tok.Matches(tok.Value()+"x") {
|
||||
t.Error("token matched something else")
|
||||
}
|
||||
var none *auth.SetupToken
|
||||
if none.Matches("") || none.Matches("anything") {
|
||||
t.Error("a nil token must fail closed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
@@ -42,6 +43,15 @@ func (h *handlers) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
h.resetLimit.Record(addr)
|
||||
writeErr(w, apierror.BadRequest("invalid_token", ""))
|
||||
return
|
||||
}
|
||||
@@ -69,6 +80,7 @@ func (h *handlers) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if rows == 0 {
|
||||
h.resetLimit.Record(addr)
|
||||
writeErr(w, apierror.BadRequest("invalid_token", ""))
|
||||
return
|
||||
}
|
||||
@@ -92,6 +104,13 @@ func (h *handlers) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -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{
|
||||
Username: prefixed,
|
||||
PasswordHash: string(hash),
|
||||
ApiToken: "test-api-token-" + prefixed,
|
||||
ApiTokenHash: "test-api-token-" + prefixed,
|
||||
IsAdmin: isAdmin,
|
||||
})
|
||||
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) {
|
||||
h, _ := testHandlers(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login",
|
||||
|
||||
@@ -3,7 +3,10 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"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.
|
||||
// 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) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||
)
|
||||
|
||||
@@ -151,8 +152,12 @@ func (h *handlers) hydrateUpserts(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gains, err := library.ReplayGainForAlbums(ctx, q, uuids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, a := range albums {
|
||||
b, _ := json.Marshal(toAlbumSyncView(a))
|
||||
b, _ := json.Marshal(toAlbumSyncView(a, gains[a.ID]))
|
||||
out["album"] = append(out["album"], b)
|
||||
}
|
||||
}
|
||||
@@ -162,8 +167,12 @@ func (h *handlers) hydrateUpserts(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gains, err := library.ReplayGainForTracks(ctx, q, uuids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, t := range tracks {
|
||||
b, _ := json.Marshal(toTrackSyncView(t))
|
||||
b, _ := json.Marshal(toTrackSyncView(t, gains[t.ID]))
|
||||
out["track"] = append(out["track"], b)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ package api
|
||||
|
||||
import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||
)
|
||||
|
||||
@@ -49,9 +50,14 @@ type albumSyncView struct {
|
||||
ReleaseDate *string `json:"release_date"`
|
||||
CoverArtPath *string `json:"cover_art_path"`
|
||||
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
|
||||
if a.ReleaseDate.Valid {
|
||||
s := a.ReleaseDate.Time.Format("2006-01-02")
|
||||
@@ -65,6 +71,8 @@ func toAlbumSyncView(a dbq.Album) albumSyncView {
|
||||
ReleaseDate: releaseDate,
|
||||
CoverArtPath: a.CoverArtPath,
|
||||
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
|
||||
// operator concern and lives on the admin surface.
|
||||
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{
|
||||
ID: syncpkg.FormatUUID(t.ID),
|
||||
AlbumID: syncpkg.FormatUUID(t.AlbumID),
|
||||
@@ -107,6 +120,8 @@ func toTrackSyncView(t dbq.Track) trackSyncView {
|
||||
FileFormat: t.FileFormat,
|
||||
Genre: t.Genre,
|
||||
Missing: t.MissingSince.Valid,
|
||||
TrackGain: g.TrackGain,
|
||||
TrackPeak: g.TrackPeak,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"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.
|
||||
@@ -53,13 +54,13 @@ func TestArtistSyncView_WireKeys(t *testing.T) {
|
||||
|
||||
func TestAlbumSyncView_WireKeys(t *testing.T) {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertJSONKeys(t, "album", b, []string{
|
||||
"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,
|
||||
FilePath: "x", FileFormat: "flac",
|
||||
}
|
||||
b, err := json.Marshal(toTrackSyncView(tr))
|
||||
b, err := json.Marshal(toTrackSyncView(tr, library.ReplayGain{}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertJSONKeys(t, "track", b, []string{
|
||||
"id", "album_id", "artist_id", "title", "duration_ms",
|
||||
"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.
|
||||
func TestTrackSyncView_MissingReflectsTheMark(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
|
||||
gone := present
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
variant := "discover"
|
||||
p := dbq.Playlist{
|
||||
|
||||
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
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{
|
||||
"/api/artists",
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
@@ -61,6 +62,19 @@ func (h *handlers) handleChangePassword(w http.ResponseWriter, r *http.Request)
|
||||
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)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -13,23 +13,11 @@ type apiTokenResp struct {
|
||||
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.
|
||||
//
|
||||
// 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) {
|
||||
user, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
@@ -42,11 +30,10 @@ func (h *handlers) handleRegenerateMyAPIToken(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
q := dbq.New(h.pool)
|
||||
updated, err := q.RegenerateApiToken(r.Context(), dbq.RegenerateApiTokenParams{
|
||||
ID: user.ID,
|
||||
ApiToken: newToken,
|
||||
})
|
||||
if err != nil {
|
||||
if err := q.RegenerateApiToken(r.Context(), dbq.RegenerateApiTokenParams{
|
||||
ID: user.ID,
|
||||
ApiTokenHash: auth.HashAPIToken(newToken),
|
||||
}); err != nil {
|
||||
h.logger.Error("regenerate api token: update failed", "err", err)
|
||||
writeErr(w, apierror.Internal(err))
|
||||
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)
|
||||
|
||||
writeJSON(w, http.StatusOK, apiTokenResp{APIToken: updated.ApiToken})
|
||||
writeJSON(w, http.StatusOK, apiTokenResp{APIToken: newToken})
|
||||
}
|
||||
|
||||
@@ -8,47 +8,23 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func newMeTokenRouter(h *handlers) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/me/api-token", h.handleGetMyAPIToken)
|
||||
r.Post("/api/me/api-token", h.handleRegenerateMyAPIToken)
|
||||
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) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "tok2", "pw", false)
|
||||
oldToken := user.ApiToken
|
||||
oldHash := user.ApiTokenHash
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/me/api-token", nil)
|
||||
req = withUser(req, user)
|
||||
@@ -65,20 +41,20 @@ func TestRegenerateAPIToken_IssuesNewToken(t *testing.T) {
|
||||
if resp.APIToken == "" {
|
||||
t.Fatalf("api_token is empty")
|
||||
}
|
||||
if resp.APIToken == oldToken {
|
||||
t.Errorf("api_token unchanged after regenerate")
|
||||
}
|
||||
|
||||
// Verify DB row has the new token and old token no longer matches.
|
||||
var dbToken string
|
||||
// The DB holds the new key's hash, never the key, and the old key no
|
||||
// longer matches.
|
||||
var dbHash string
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
"SELECT api_token FROM users WHERE id = $1", user.ID).Scan(&dbToken); err != nil {
|
||||
t.Fatalf("read token: %v", err)
|
||||
"SELECT api_token_hash FROM users WHERE id = $1", user.ID).Scan(&dbHash); err != nil {
|
||||
t.Fatalf("read token hash: %v", err)
|
||||
}
|
||||
if dbToken != resp.APIToken {
|
||||
t.Errorf("DB api_token = %q, want %q", dbToken, resp.APIToken)
|
||||
if dbHash != auth.HashAPIToken(resp.APIToken) {
|
||||
t.Errorf("DB api_token_hash = %q, want hash of the returned key", dbHash)
|
||||
}
|
||||
if dbToken == oldToken {
|
||||
t.Errorf("old token still in DB after regenerate")
|
||||
if dbHash == oldHash {
|
||||
t.Errorf("old key hash still in DB after regenerate")
|
||||
}
|
||||
if dbHash == resp.APIToken {
|
||||
t.Errorf("DB holds the raw key")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// stale art clears within 24h after a re-scan. ServeContent below
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -197,6 +197,6 @@ func (h *handlers) handleGetStream(w http.ResponseWriter, r *http.Request) {
|
||||
// max-age + immutable lets the client cache (LockCachingAudioSource
|
||||
// on the Flutter side, browser cache on web) skip even the
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -406,7 +406,7 @@ func (h *handlers) handleGetPlaylistCover(w http.ResponseWriter, r *http.Request
|
||||
// (system playlists re-rendered on rebuild, user playlists when
|
||||
// modified). 5 minutes is short enough for normal edits to feel
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,10 @@ func Unauthorized(code, message string) *Error {
|
||||
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 {
|
||||
return &Error{Status: 500, Code: "server_error", Message: "internal server error", Cause: cause}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,10 @@ const (
|
||||
// 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.
|
||||
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;
|
||||
|
||||
@@ -169,6 +169,8 @@ func TestWrite_AllActionConstantsArePersisted(t *testing.T) {
|
||||
audit.ActionForgotPasswordInit,
|
||||
audit.ActionPasswordResetByEmail,
|
||||
audit.ActionDuplicateMerge,
|
||||
audit.ActionSubsonicPasswordSet,
|
||||
audit.ActionSubsonicPasswordClear,
|
||||
}
|
||||
for _, a := range actions {
|
||||
if err := audit.Write(context.Background(), pool, nilUUID, nilUUID, a, nil); err != nil {
|
||||
|
||||
@@ -65,6 +65,36 @@ func ClientIP(r *http.Request, trustedProxyHops int) string {
|
||||
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
|
||||
// single X-Real-IP value when XFF is absent.
|
||||
//
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -41,6 +42,15 @@ func HashSessionToken(token string) []byte {
|
||||
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
|
||||
// malformed hash so callers don't need to distinguish "hash invalid" from
|
||||
// "password wrong" — both are auth failures from the client's perspective.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
t.Helper()
|
||||
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 {
|
||||
t.Fatalf("seed user %s: %v", name, err)
|
||||
|
||||
@@ -84,6 +84,22 @@ func (q *Queries) GcDeleteExpiredPasswordResets(ctx context.Context) (int64, err
|
||||
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
|
||||
DELETE FROM scrobble_queue
|
||||
WHERE status = 'failed'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -201,6 +201,16 @@ type Album struct {
|
||||
CoverArtSourcesVersion int32
|
||||
}
|
||||
|
||||
type AlbumLoudness struct {
|
||||
AlbumID pgtype.UUID
|
||||
IntegratedLufs *float32
|
||||
TruePeakDbtp *float32
|
||||
TracksTotal int32
|
||||
TracksSettled int32
|
||||
InputsDigest string
|
||||
ComputedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type Artist struct {
|
||||
ID pgtype.UUID
|
||||
Name string
|
||||
@@ -417,6 +427,13 @@ type LidarrRequest struct {
|
||||
LidarrAddConfirmedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type LoudnessSetting struct {
|
||||
ID bool
|
||||
Enabled bool
|
||||
BackfillConcurrency int32
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type MissingReacquisition struct {
|
||||
AlbumID pgtype.UUID
|
||||
Attempts int32
|
||||
@@ -430,6 +447,7 @@ type MissingReacquisition struct {
|
||||
type NetworkSetting struct {
|
||||
ID bool
|
||||
TrustedProxyHops int32
|
||||
PublicUrl string
|
||||
}
|
||||
|
||||
type PasswordReset struct {
|
||||
@@ -712,6 +730,18 @@ type TrackFingerprint struct {
|
||||
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 {
|
||||
TrackAID pgtype.UUID
|
||||
TrackBID pgtype.UUID
|
||||
@@ -730,7 +760,6 @@ type User struct {
|
||||
ID pgtype.UUID
|
||||
Username string
|
||||
PasswordHash string
|
||||
ApiToken string
|
||||
IsAdmin bool
|
||||
CreatedAt pgtype.Timestamptz
|
||||
SubsonicPassword *string
|
||||
@@ -742,6 +771,7 @@ type User struct {
|
||||
Timezone string
|
||||
TimezoneUpdatedAt pgtype.Timestamptz
|
||||
DebugModeEnabled bool
|
||||
ApiTokenHash string
|
||||
}
|
||||
|
||||
type UserInvite struct {
|
||||
|
||||
@@ -10,23 +10,34 @@ import (
|
||||
)
|
||||
|
||||
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) {
|
||||
row := q.db.QueryRow(ctx, getNetworkSettings)
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
row := q.db.QueryRow(ctx, updateTrustedProxyHops, trustedProxyHops)
|
||||
var i NetworkSetting
|
||||
err := row.Scan(&i.ID, &i.TrustedProxyHops)
|
||||
err := row.Scan(&i.ID, &i.TrustedProxyHops, &i.PublicUrl)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -69,10 +69,38 @@ func (q *Queries) DeleteSessionForUser(ctx context.Context, arg DeleteSessionFor
|
||||
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
|
||||
const deleteSessionsForUser = `-- name: DeleteSessionsForUser :execrows
|
||||
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) {
|
||||
row := q.db.QueryRow(ctx, getSessionByTokenHash, tokenHash)
|
||||
var i Session
|
||||
@@ -127,11 +155,17 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (S
|
||||
}
|
||||
|
||||
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
|
||||
// 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) {
|
||||
rows, err := q.db.Query(ctx, listSessionsForUser, userID)
|
||||
if err != nil {
|
||||
|
||||
@@ -52,15 +52,15 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) {
|
||||
}
|
||||
|
||||
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)
|
||||
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 {
|
||||
Username string
|
||||
PasswordHash string
|
||||
ApiToken string
|
||||
ApiTokenHash string
|
||||
IsAdmin bool
|
||||
DisplayName *string
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
||||
row := q.db.QueryRow(ctx, createUser,
|
||||
arg.Username,
|
||||
arg.PasswordHash,
|
||||
arg.ApiToken,
|
||||
arg.ApiTokenHash,
|
||||
arg.IsAdmin,
|
||||
arg.DisplayName,
|
||||
)
|
||||
@@ -78,7 +78,6 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
@@ -90,20 +89,21 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
&i.ApiTokenHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
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)
|
||||
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 {
|
||||
Username string
|
||||
PasswordHash string
|
||||
ApiToken string
|
||||
ApiTokenHash string
|
||||
IsAdmin bool
|
||||
DisplayName *string
|
||||
}
|
||||
@@ -115,7 +115,7 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams
|
||||
row := q.db.QueryRow(ctx, createUserAdmin,
|
||||
arg.Username,
|
||||
arg.PasswordHash,
|
||||
arg.ApiToken,
|
||||
arg.ApiTokenHash,
|
||||
arg.IsAdmin,
|
||||
arg.DisplayName,
|
||||
)
|
||||
@@ -124,7 +124,6 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
@@ -136,24 +135,25 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
&i.ApiTokenHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
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 (
|
||||
$1, $2, $3,
|
||||
(SELECT NOT EXISTS (SELECT 1 FROM users)),
|
||||
$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 {
|
||||
Username string
|
||||
PasswordHash string
|
||||
ApiToken string
|
||||
ApiTokenHash string
|
||||
DisplayName *string
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
|
||||
row := q.db.QueryRow(ctx, createUserFirstAdminRace,
|
||||
arg.Username,
|
||||
arg.PasswordHash,
|
||||
arg.ApiToken,
|
||||
arg.ApiTokenHash,
|
||||
arg.DisplayName,
|
||||
)
|
||||
var i User
|
||||
@@ -182,7 +182,6 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
@@ -194,6 +193,7 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
&i.ApiTokenHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -240,7 +240,7 @@ func (q *Queries) GetListenBrainzConfig(ctx context.Context, id pgtype.UUID) (Ge
|
||||
}
|
||||
|
||||
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
|
||||
ORDER BY created_at, id
|
||||
LIMIT 1
|
||||
@@ -260,7 +260,6 @@ func (q *Queries) GetOldestAdmin(ctx context.Context) (User, error) {
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
@@ -272,22 +271,22 @@ func (q *Queries) GetOldestAdmin(ctx context.Context) (User, error) {
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
&i.ApiTokenHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByAPIToken = `-- name: GetUserByAPIToken :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
|
||||
const getUserByAPITokenHash = `-- name: GetUserByAPITokenHash :one
|
||||
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) {
|
||||
row := q.db.QueryRow(ctx, getUserByAPIToken, apiToken)
|
||||
func (q *Queries) GetUserByAPITokenHash(ctx context.Context, apiTokenHash string) (User, error) {
|
||||
row := q.db.QueryRow(ctx, getUserByAPITokenHash, apiTokenHash)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
@@ -299,12 +298,13 @@ func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
&i.ApiTokenHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
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
|
||||
@@ -317,7 +317,6 @@ func (q *Queries) GetUserByEmail(ctx context.Context, lower string) (User, error
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
@@ -329,12 +328,13 @@ func (q *Queries) GetUserByEmail(ctx context.Context, lower string) (User, error
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
&i.ApiTokenHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -344,7 +344,6 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error)
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
@@ -356,12 +355,13 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error)
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
&i.ApiTokenHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -371,7 +371,6 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
@@ -383,6 +382,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
&i.ApiTokenHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -471,39 +471,21 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const regenerateApiToken = `-- name: RegenerateApiToken :one
|
||||
UPDATE users SET api_token = $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
|
||||
const regenerateApiToken = `-- name: RegenerateApiToken :exec
|
||||
UPDATE users SET api_token_hash = $2 WHERE id = $1
|
||||
`
|
||||
|
||||
type RegenerateApiTokenParams struct {
|
||||
ID pgtype.UUID
|
||||
ApiToken string
|
||||
ID pgtype.UUID
|
||||
ApiTokenHash string
|
||||
}
|
||||
|
||||
// Self-service: caller wants a new API token. Used by the /settings
|
||||
// API Token card's "Regenerate" button.
|
||||
func (q *Queries) RegenerateApiToken(ctx context.Context, arg RegenerateApiTokenParams) (User, error) {
|
||||
row := q.db.QueryRow(ctx, regenerateApiToken, arg.ID, arg.ApiToken)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&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
|
||||
// API Token card's "Regenerate" button. Only the hash is stored; the
|
||||
// handler returns the raw key once.
|
||||
func (q *Queries) RegenerateApiToken(ctx context.Context, arg RegenerateApiTokenParams) error {
|
||||
_, err := q.db.Exec(ctx, regenerateApiToken, arg.ID, arg.ApiTokenHash)
|
||||
return err
|
||||
}
|
||||
|
||||
const resetUserPassword = `-- name: ResetUserPassword :exec
|
||||
@@ -530,7 +512,7 @@ const setDebugMode = `-- name: SetDebugMode :one
|
||||
UPDATE users
|
||||
SET debug_mode_enabled = $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
|
||||
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 {
|
||||
@@ -548,7 +530,6 @@ func (q *Queries) SetDebugMode(ctx context.Context, arg SetDebugModeParams) (Use
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
@@ -560,6 +541,7 @@ func (q *Queries) SetDebugMode(ctx context.Context, arg SetDebugModeParams) (Use
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
&i.ApiTokenHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -607,7 +589,8 @@ type SetSubsonicPasswordParams struct {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
_, err := q.db.Exec(ctx, setSubsonicPassword, arg.ID, arg.SubsonicPassword)
|
||||
return err
|
||||
@@ -617,7 +600,7 @@ const updateUserAdmin = `-- name: UpdateUserAdmin :one
|
||||
UPDATE users
|
||||
SET is_admin = $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
|
||||
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 {
|
||||
@@ -634,7 +617,6 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
@@ -646,6 +628,7 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
&i.ApiTokenHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -654,7 +637,7 @@ const updateUserAutoApprove = `-- name: UpdateUserAutoApprove :one
|
||||
UPDATE users
|
||||
SET auto_approve_requests = $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
|
||||
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 {
|
||||
@@ -670,7 +653,6 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
@@ -682,6 +664,7 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
&i.ApiTokenHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -691,7 +674,7 @@ UPDATE users
|
||||
SET display_name = $2,
|
||||
email = $3
|
||||
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 {
|
||||
@@ -709,7 +692,6 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
@@ -721,6 +703,7 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
&i.ApiTokenHash,
|
||||
)
|
||||
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()
|
||||
);
|
||||
@@ -68,3 +68,10 @@ UPDATE system_playlist_runs
|
||||
DELETE FROM password_resets
|
||||
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');
|
||||
|
||||
-- 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';
|
||||
|
||||
@@ -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,3 +3,6 @@ SELECT * FROM network_settings WHERE id = true;
|
||||
|
||||
-- name: UpdateTrustedProxyHops :one
|
||||
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 *;
|
||||
|
||||
@@ -7,7 +7,18 @@ VALUES ($1, $2, $3, sqlc.arg(ip), sqlc.arg(ip))
|
||||
RETURNING *;
|
||||
|
||||
-- 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
|
||||
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
|
||||
-- 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.
|
||||
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
|
||||
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
|
||||
-- doesn't log them out of the page they just used to invoke it.
|
||||
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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- 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)
|
||||
RETURNING *;
|
||||
|
||||
@@ -17,7 +17,7 @@ RETURNING *;
|
||||
-- and the second caller's INSERT fails with a unique violation. The
|
||||
-- caller (registration handler) can retry as a regular non-admin in
|
||||
-- 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 (
|
||||
$1, $2, $3,
|
||||
(SELECT NOT EXISTS (SELECT 1 FROM users)),
|
||||
@@ -28,15 +28,16 @@ RETURNING *;
|
||||
-- name: GetUserByUsername :one
|
||||
SELECT * FROM users WHERE username = $1;
|
||||
|
||||
-- name: GetUserByAPIToken :one
|
||||
SELECT * FROM users WHERE api_token = $1;
|
||||
-- name: GetUserByAPITokenHash :one
|
||||
SELECT * FROM users WHERE api_token_hash = $1;
|
||||
|
||||
-- name: CountUsers :one
|
||||
SELECT count(*) FROM users;
|
||||
|
||||
-- name: SetSubsonicPassword :exec
|
||||
-- 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;
|
||||
|
||||
-- name: GetUserByID :one
|
||||
@@ -87,7 +88,7 @@ WHERE u.id = $1;
|
||||
-- Admin-driven user creation. Distinct from CreateUser/CreateUserFirstAdminRace:
|
||||
-- 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.
|
||||
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)
|
||||
RETURNING *;
|
||||
|
||||
@@ -141,11 +142,11 @@ UPDATE users
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: RegenerateApiToken :one
|
||||
-- name: RegenerateApiToken :exec
|
||||
-- Self-service: caller wants a new API token. Used by the /settings
|
||||
-- API Token card's "Regenerate" button.
|
||||
UPDATE users SET api_token = $2 WHERE id = $1
|
||||
RETURNING *;
|
||||
-- API Token card's "Regenerate" button. Only the hash is stored; the
|
||||
-- handler returns the raw key once.
|
||||
UPDATE users SET api_token_hash = $2 WHERE id = $1;
|
||||
|
||||
-- name: GetUserByEmail :one
|
||||
-- Used by forgot-password lookup. Lowercase comparison both sides
|
||||
|
||||
@@ -91,6 +91,8 @@ var dataTables = []string{
|
||||
"duplicate_groups",
|
||||
"duplicate_sweeps",
|
||||
"track_fingerprints", // M400
|
||||
"track_loudness", // M464
|
||||
"album_loudness", // M464
|
||||
"tracks",
|
||||
"albums",
|
||||
"artists",
|
||||
@@ -141,4 +143,11 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
|
||||
); err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
// - GcExpireScrobbleQueueFailedRows (#567)
|
||||
// - GcResetStuckSystemPlaylistRuns (#574)
|
||||
// - GcDeleteExpiredPasswordResets (#575)
|
||||
// - GcDeleteExpiredSessions (M462 #4978 — idle 30d / absolute 1y)
|
||||
// - GcPruneDiagnostics (M9 — diagnostics 30d retention)
|
||||
// - GcDeleteExpiredSuggestionSnoozes (#2374 — snoozes expire, then go)
|
||||
// - 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, "reset_stuck_system_runs", q.GcResetStuckSystemPlaylistRuns)
|
||||
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, "delete_expired_suggestion_snoozes", q.GcDeleteExpiredSuggestionSnoozes)
|
||||
// Tags before state: if the process dies between the two, a candidate left
|
||||
|
||||
@@ -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{
|
||||
Username: dbtest.TestUserPrefix + name,
|
||||
PasswordHash: "test-hash",
|
||||
ApiToken: "test-token-" + name,
|
||||
ApiTokenHash: "test-token-" + name,
|
||||
IsAdmin: false,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ func newMergeFixture(t *testing.T) mergeFixture {
|
||||
user := func(name string) dbq.User {
|
||||
t.Helper()
|
||||
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 {
|
||||
t.Fatalf("user %s: %v", name, err)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -402,6 +402,13 @@ func (s *Scanner) scanFile(
|
||||
// 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)
|
||||
}
|
||||
// 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 {
|
||||
|
||||
+149
@@ -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
|
||||
@@ -46,7 +46,7 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
|
||||
t.Helper()
|
||||
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
|
||||
Username: dbtest.TestUserPrefix + name, PasswordHash: "x",
|
||||
ApiToken: name + "-token", IsAdmin: false,
|
||||
ApiTokenHash: name + "-token", IsAdmin: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed user %s: %v", name, err)
|
||||
|
||||
@@ -50,7 +50,7 @@ func newPool(t *testing.T) *pgxpool.Pool {
|
||||
func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID {
|
||||
t.Helper()
|
||||
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 {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
@@ -206,7 +206,7 @@ func TestListForUser_OnlyOwnRows(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
alice := seedUser(t, pool)
|
||||
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 {
|
||||
t.Fatalf("seed bob: %v", err)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -32,13 +34,18 @@ const (
|
||||
// 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")
|
||||
|
||||
// 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.
|
||||
type Service struct {
|
||||
pool *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
hops int
|
||||
mu sync.RWMutex
|
||||
hops int
|
||||
publicURL string
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
s.hops = int(row.TrustedProxyHops)
|
||||
s.publicURL = row.PublicUrl
|
||||
return s, nil
|
||||
}
|
||||
|
||||
@@ -106,3 +114,53 @@ func (s *Service) SetHops(ctx context.Context, hops int) error {
|
||||
}
|
||||
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
Reference in New Issue
Block a user