Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2985f3841 | ||
|
|
71d4335584 | ||
|
|
702b48ce36 | ||
|
|
d7a8e5f300 | ||
|
|
cba77a5187 | ||
|
|
eff3d88931 | ||
|
|
f70df9f827 | ||
|
|
4ce47397a9 | ||
|
|
721154847e | ||
|
|
633d4f591f | ||
|
|
f5dd4462de | ||
|
|
31190657d8 | ||
|
|
ecfa056d4d | ||
|
|
f367eeaa9d | ||
|
|
270ad7a71b | ||
|
|
17212e9eb4 | ||
|
|
8f4b76a638 | ||
|
|
aeb8781c4e |
+19
-5
@@ -6,9 +6,20 @@
|
||||
**/build
|
||||
web/build
|
||||
|
||||
# Flutter mobile client — built separately on developer machines / Flutter CI.
|
||||
# Including it in the Go build context wastes ~70 files and invalidates the
|
||||
# `COPY . .` layer cache on every Flutter-only change.
|
||||
# The Android client — built by its own job, never from this context. The APK
|
||||
# reaches the image through client/, downloaded as a CI artifact, so nothing
|
||||
# here reads android/ sources.
|
||||
#
|
||||
# This block named `flutter_client/` until 2026-09-10 and lost its PATTERN when
|
||||
# that tree was deleted, leaving a comment describing an exclusion that was no
|
||||
# longer happening. android/ never took its place, so 4.1 MB of Gradle project
|
||||
# has been entering the context and busting the `COPY . .` layer on every
|
||||
# Android-only change.
|
||||
android/
|
||||
|
||||
# Local `make build` output — an 18 MB binary the image never uses, since the
|
||||
# builder stage compiles its own.
|
||||
bin/
|
||||
|
||||
# Docs and IDE noise
|
||||
docs/
|
||||
@@ -26,5 +37,8 @@ docs/
|
||||
!.env.example
|
||||
|
||||
# CI workflow files don't need to ship in the image.
|
||||
.forgejo/
|
||||
.github/
|
||||
#
|
||||
# This said `.forgejo/` and `.github/` — neither of which this repo has. Gitea
|
||||
# Actions reads `.gitea/`, so the one directory that actually exists was the
|
||||
# one not excluded, and every workflow edit invalidated the context.
|
||||
.gitea/
|
||||
|
||||
@@ -80,15 +80,12 @@ jobs:
|
||||
|
||||
- name: Upload debug APK
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
# Mirrored action, never actions/upload-artifact. @v4+ throws
|
||||
# GHESNotSupportedError client-side on the hostname (no server setting
|
||||
# reaches that check), and @v3 is worse — it reports success while Gitea
|
||||
# serves artifacts back only through the v4 API, so the upload is stored
|
||||
# and invisible to every retrieval path. @v3 is what left 72 unreachable
|
||||
# artifacts on this repo. Pinned by SHA because the mirror auto-syncs;
|
||||
# full URL because DEFAULT_ACTIONS_URL sends bare owner/repo to github.com.
|
||||
# See Scribe issues 2255 / 2270.
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
# 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
|
||||
|
||||
+115
-49
@@ -2,11 +2,31 @@ name: release
|
||||
|
||||
# Builds and pushes the minstrel container image to the Gitea registry.
|
||||
#
|
||||
# push to dev → :dev (freshly-built dev APK bundled)
|
||||
# push to main → :main and :latest (latest-release APK bundled)
|
||||
# push tag vYYYY.MM.DD.HHMM → :vYYYY.MM.DD.HHMM and :latest (fresh APK bundled)
|
||||
# push to dev → :dev (freshly-built dev APK bundled)
|
||||
# push to main → :latest + :<sha> (latest-release APK bundled)
|
||||
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled)
|
||||
# workflow_dispatch → manual trigger (same rules based on the ref)
|
||||
#
|
||||
# That is the whole tag map, and it is family rule 145 + 147 as written.
|
||||
#
|
||||
# :<sha> on main is the ROLLBACK UNIT — every production commit addressable
|
||||
# without a release ceremony. It is minted only on main, where rollback is
|
||||
# actually worth having: merges are gated (rule 2) so they number in the dozens
|
||||
# per year, while on dev they would be one per push, forever, for a channel
|
||||
# whose entire contract is that it moves.
|
||||
#
|
||||
# There are NO :<version> image tags. This repo published :vYYYY.MM.DD.HHMM
|
||||
# until 2026-09-10 and it was the inverse of the rule on both counts — minting
|
||||
# a version tag nobody pinned while the rollback unit the rule names did not
|
||||
# exist here at all. Git and the build's own self-reported version answer
|
||||
# "which build is this"; a third name for the same thing is upkeep for a model
|
||||
# we do not run. Operator, 2026-09-10: "only things like the APK need that kind
|
||||
# of versioning for their update process."
|
||||
#
|
||||
# There is no :main either. :latest tracks main's tip with no gate between them
|
||||
# (rule 147), so a second name for the same image sends readers looking for a
|
||||
# distinction that does not exist.
|
||||
#
|
||||
# The dev channel exists so testing a build does not require shipping one.
|
||||
# Before it, the only way to get an APK onto a phone was to cut a release,
|
||||
# which made `main` the staging area by default. `:dev` carries its own
|
||||
@@ -212,13 +232,12 @@ jobs:
|
||||
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
|
||||
|
||||
- name: Upload APK as workflow artifact
|
||||
# Mirrored action, never actions/upload-artifact — @v4+ refuses on the
|
||||
# hostname, @v3 uploads something Gitea will never serve back. This is
|
||||
# the producing half of a pair: image-release downloads `minstrel-apk`
|
||||
# below with the matching download-artifact mirror. Both must stay on
|
||||
# the v4 protocol — mixing a v3 upload with a v4 download (or the
|
||||
# reverse) yields an empty listing, not an error. See Scribe 2255 / 2270.
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
# Stock action (snippet #2271) — never @v3, which uploads something Gitea
|
||||
# will never serve back. This is the producing half of a pair:
|
||||
# image-release downloads `minstrel-apk` below. Any upload v4+ pairs with
|
||||
# any download v4+ on this forge (every combination tested 2026-09-10,
|
||||
# Scribe spike #3843), so the two pins need not move together.
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: minstrel-apk
|
||||
path: android/app/build/outputs/apk/release/app-release.apk
|
||||
@@ -312,11 +331,16 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Shallow is fine here. This job used to need full history + tags to
|
||||
# re-derive the bundled APK's version from the tagged commit; it now
|
||||
# downloads the sidecar the release recorded, and touches git for
|
||||
# nothing. MINSTREL_VERSION comes from GITHUB_REF, not from git.
|
||||
fetch-depth: 1
|
||||
# Full history, and rule 149 names this specifically: any job that
|
||||
# DERIVES the version name needs it, because a shallow clone changes
|
||||
# what git-derived values resolve to WITHOUT failing — a too-low
|
||||
# value, silently, with every lane green.
|
||||
#
|
||||
# This job was depth-1 while it took the version from GITHUB_REF. It
|
||||
# now runs ci/version.sh itself, because with :<version> image tags
|
||||
# gone the server's self-reported version is the only thing that says
|
||||
# which build an image is.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect buildable project
|
||||
id: guard
|
||||
@@ -334,30 +358,68 @@ jobs:
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# THE VERSION, and it is derived the same way on every ref — the
|
||||
# branch decides the CHANNEL, never the version (family rule 149).
|
||||
#
|
||||
# This used to be three different things: the literal string "main"
|
||||
# on main, "dev" on dev, and the tag name on a tag. None of them
|
||||
# ordered, and the first two were the same string forever — two dev
|
||||
# images eight weeks apart were indistinguishable in the UI. That
|
||||
# mattered little while :vYYYY.MM.DD.HHMM existed to identify a
|
||||
# build; with version image tags gone, this IS how an operator tells
|
||||
# which build a container is running.
|
||||
#
|
||||
# `sed -n s///p` rather than `grep`: it exits 0 when nothing matches,
|
||||
# so the empty check below is actually reachable. A grep here would
|
||||
# kill the step at the assignment under the runner's pipefail — the
|
||||
# exact bug that took down the first main build after the version
|
||||
# rework.
|
||||
VERSION="$(ci/version.sh HEAD | sed -n 's/^name=//p')"
|
||||
if [ -z "${VERSION}" ]; then
|
||||
echo "::error::could not derive a build version from ci/version.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Release build: ${VERSION} + latest"
|
||||
# A release refreshes the CHANNEL and mints nothing else.
|
||||
#
|
||||
# The tag build exists to produce the signed APK and attach it to
|
||||
# the release; the image it rebuilds is the SAME SOURCE as the main
|
||||
# build minutes earlier, differing only in which APK is baked in.
|
||||
# Rule 145 is explicit about that case: when the same source is
|
||||
# rebuilt with different contents, publish the moving channel tag
|
||||
# and never a commit-addressable one.
|
||||
#
|
||||
# :latest must move here rather than waiting for the next main
|
||||
# push, or the channel would carry the PREVIOUS release's APK
|
||||
# indefinitely — a channel that cannot refresh itself (rule 146).
|
||||
CHANNEL=stable
|
||||
echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK"
|
||||
elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then
|
||||
# The rolling test channel, and :dev ALONE — deliberately no
|
||||
# per-commit tag. A rolling channel is rolling by definition, so a
|
||||
# commit-addressable image here would be a rollback target nobody
|
||||
# has ever pulled, accumulating in the registry forever. Recovery
|
||||
# on dev is to fix forward.
|
||||
CHANNEL=dev
|
||||
echo "args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT"
|
||||
echo "version=dev" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Dev-branch build: :dev"
|
||||
echo "::notice::Dev-branch build ${VERSION}: :dev"
|
||||
else
|
||||
# Main is the protected, post-PR-merge branch. Treat it as the
|
||||
# rolling stable channel — every main push moves :latest.
|
||||
# Pinned consumers can target :vYYYY.MM.DD.HHMM, which never
|
||||
# moves; everyone else gets the newest main.
|
||||
echo "args=-t ${IMAGE}:main -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "version=main" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Main-branch build: :main + :latest"
|
||||
# The production line: :latest tracks main's tip (rule 147) and
|
||||
# :<sha> is the rollback unit (rule 145). Full 40-char SHA, matching
|
||||
# the family's other repos, so a rollback target is addressable
|
||||
# straight from the commit anyone is reading.
|
||||
CHANNEL=stable
|
||||
echo "args=-t ${IMAGE}:latest -t ${IMAGE}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Main-branch build ${VERSION}: :latest + :${GITHUB_SHA}"
|
||||
fi
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Registry login
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
shell: bash
|
||||
@@ -372,20 +434,11 @@ jobs:
|
||||
if: >-
|
||||
steps.guard.outputs.ready == 'true' &&
|
||||
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
|
||||
# Consuming half of the pair — never actions/download-artifact. Same fork,
|
||||
# same reason: upstream's client-side GHES check rejects this hostname
|
||||
# before it connects. bvandeusen/download-artifact mirrors
|
||||
# code.forgejo.org/forgejo/download-artifact.
|
||||
#
|
||||
# SHA below is that fork's `v6` tag. Match on @actions/artifact, NOT on
|
||||
# the action's own version number — the two actions release on unrelated
|
||||
# cadences, and download v5 would pair a ^2.3.2 client with this file's
|
||||
# ^4.0.0 uploader. v6 is the tag whose bundled library major (^4.0.0) is
|
||||
# the same one proven against this instance by the upload side.
|
||||
# Deliberately NOT v7: it moves to node24 and upstream requires runner
|
||||
# >= 2.327.1 for it, which act_runner does not claim to satisfy.
|
||||
# Pinned, not tagged — the mirror auto-syncs every 8h.
|
||||
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
|
||||
# Consuming half of the pair: stock download-artifact, which works here for
|
||||
# the same reason as the upload (gitea/runner 3.x edits the GHES refusal
|
||||
# out of the bundle; snippet #2271). v8 runs on node24, which every
|
||||
# CI-runner image carries — the runner uses the image's own node.
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: minstrel-apk
|
||||
path: client/
|
||||
@@ -478,6 +531,7 @@ jobs:
|
||||
run: |
|
||||
docker buildx build \
|
||||
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
|
||||
--build-arg MINSTREL_CHANNEL="${{ steps.tags.outputs.channel }}" \
|
||||
--push ${{ steps.tags.outputs.args }} .
|
||||
|
||||
# Verifies a tag release actually ended up complete, and names the specific
|
||||
@@ -488,8 +542,8 @@ jobs:
|
||||
# `failure` with none executed and image-release showed `skipped`. The run was
|
||||
# red, but the *release page rendered fine*, and `main`'s own push build had
|
||||
# already moved `:latest`, so the code was deployable and nothing looked
|
||||
# obviously wrong. The release was simply missing its APK and its immutable
|
||||
# `:vYYYY.MM.DD` image, which is easy to skim past.
|
||||
# obviously wrong. The release was simply missing its APK and its image,
|
||||
# which is easy to skim past.
|
||||
#
|
||||
# This job cannot prevent that (the cause was a runner failing to launch, not
|
||||
# anything in this file). What it does is turn an incomplete release into an
|
||||
@@ -540,18 +594,30 @@ jobs:
|
||||
# missing when v2026.08.07 had to be re-cut. `always()` on this job means
|
||||
# it runs even when image-release failed, so without this the guard would
|
||||
# cheerfully verify an incomplete release.
|
||||
- name: Immutable image tag must exist
|
||||
#
|
||||
# This asserted `:${TAG}` — the :vYYYY.MM.DD.HHMM image — until
|
||||
# 2026-09-10. Version image tags are no longer published (rule 145), so
|
||||
# that assertion would now fail every release for a tag nothing mints.
|
||||
# The rollback target it was really protecting is the :<sha> image, which
|
||||
# main's own build published for this same commit before the tag was cut.
|
||||
#
|
||||
# Checking it here earns its keep twice over: it still catches an image
|
||||
# push that silently did not happen, and it additionally proves the
|
||||
# ORDERING — a tag cut on a commit whose main build never completed has
|
||||
# no rollback target, and that is worth failing on rather than
|
||||
# discovering during an incident.
|
||||
- name: Rollback image must exist for the tagged commit
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
IMAGE="git.fabledsword.com/bvandeusen/minstrel"
|
||||
|
||||
echo "${{ secrets.CI_TOKEN }}" \
|
||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
if ! docker manifest inspect "${IMAGE}:${TAG}" > /dev/null 2>&1; then
|
||||
echo "::error::image ${IMAGE}:${TAG} was never pushed — the release tag has no immutable image, so there is nothing to pin or roll back to. Re-run this workflow run."
|
||||
if ! docker manifest inspect "${IMAGE}:${GITHUB_SHA}" > /dev/null 2>&1; then
|
||||
echo "::error::image ${IMAGE}:${GITHUB_SHA} does not exist — this commit has no rollback target."
|
||||
echo "::error::That image is published by the MAIN build of this commit, not by the tag build. If main's build never ran or failed, fix that first; a release whose commit cannot be rolled back to is the thing this check exists to refuse."
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::image verified: ${IMAGE}:${TAG}"
|
||||
echo "::notice::rollback target verified: ${IMAGE}:${GITHUB_SHA}"
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# `make build` output. bin/minstrel was tracked until 2026-09-10 — an 18 MB
|
||||
# binary committed by accident, last refreshed by a commit about web test
|
||||
# mocks, and re-dirtied by every local build since.
|
||||
bin/
|
||||
|
||||
# Bundled Android APK + version sidecar (#397). Populated by CI for
|
||||
# tag releases; never committed. README in client/ explains the flow.
|
||||
client/minstrel.apk
|
||||
|
||||
+20
-5
@@ -15,17 +15,32 @@ COPY . .
|
||||
# Overwrite the committed placeholder with the freshly-built SPA assets.
|
||||
COPY --from=web /web/build ./web/build
|
||||
ENV CGO_ENABLED=0
|
||||
# Version stamping: release.yml passes the git tag via MINSTREL_VERSION
|
||||
# build-arg; local `docker build` falls back to "dev". Surfaced at
|
||||
# /healthz for operator-side image-version verification.
|
||||
# Version stamping. release.yml passes the DERIVED version name
|
||||
# (YYYY.MM.DD.HHMM) and the lane's channel; a local `docker build` falls back
|
||||
# to "dev"/"local". Both are surfaced at /healthz.
|
||||
#
|
||||
# These are two values on purpose (family rule 149): the same commit built on
|
||||
# dev and on main reports the same NAME and differs only in CHANNEL. Folding
|
||||
# the channel into the version string is what the rule forbids — the version
|
||||
# used to BE the channel word here ("main"/"dev"), which meant two dev images
|
||||
# eight weeks apart were indistinguishable.
|
||||
ARG MINSTREL_VERSION=dev
|
||||
ARG MINSTREL_CHANNEL=local
|
||||
RUN go build -trimpath \
|
||||
-ldflags="-s -w -X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}'" \
|
||||
-ldflags="-s -w \
|
||||
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}' \
|
||||
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerChannel=${MINSTREL_CHANNEL}'" \
|
||||
-o /out/minstrel ./cmd/minstrel
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
# ffmpeg: duration probes and the exact-tier audio hash (a SHA-256 of the
|
||||
# encoded audio packets, so no decode). libchromaprint-tools: fpcalc, the
|
||||
# acoustic fingerprint that tells the same recording at two bitrates apart
|
||||
# from two different recordings (M400). Both are baked in at build time so a
|
||||
# deployed instance never fetches either (rule 164); fpcalc is shelled out
|
||||
# rather than bound because CGO_ENABLED=0 above rules out cgo.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg libchromaprint-tools \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd --system --gid 1000 minstrel \
|
||||
|
||||
@@ -37,8 +37,12 @@ services:
|
||||
ports: ['4533:4533']
|
||||
volumes:
|
||||
# Your music library. Point ./music at wherever your audio files
|
||||
# live. Mounted read-only — Minstrel never writes to your library.
|
||||
- ./music:/music:ro
|
||||
# live. Writable, because Minstrel deletes a file when an admin asks
|
||||
# it to (for example, quarantine's "Delete file"). It never moves,
|
||||
# renames or retags anything. The container runs as uid 1000, so that
|
||||
# user needs write access to the folders. Mount it :ro to forbid even
|
||||
# deletes: those actions then refuse, say why, and delete nothing.
|
||||
- ./music:/music
|
||||
# Generated data: playlist cover collages, artist art, caches.
|
||||
# The path must match MINSTREL_STORAGE_DATA_DIR, which the image
|
||||
# sets to /app/data — keep this mount on /app/data or your cache
|
||||
@@ -47,7 +51,7 @@ services:
|
||||
environment:
|
||||
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
|
||||
# Colon-separated library roots to scan; must match the container
|
||||
# path of the read-only music mount above (/music here).
|
||||
# path of the music mount above (/music here).
|
||||
MINSTREL_LIBRARY_SCAN_PATHS: /music
|
||||
depends_on: [db]
|
||||
|
||||
@@ -112,18 +116,21 @@ Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommen
|
||||
|
||||
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
|
||||
|
||||
- `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators.
|
||||
- `:vYYYY.MM.DD.HHMM` — immutable release tags, never moved or deleted. Pin one for a deployment you don't want changing under you. The tag is the build's own version name with a `v` in front, derived from the tagged commit's UTC timestamp, so two releases can never collide and a re-cut is simply a new tag.
|
||||
- `:main` — the rolling post-merge tip. Same image as `:latest` at push time; choose it if you want to track `main` explicitly rather than the release line.
|
||||
- `:dev` — the rolling test channel, rebuilt on every push to `dev` and carrying its own freshly-built Android APK. Run this when you want to try something before it ships. It moves constantly, has no per-commit tag to pin, and its only recovery path is forward — if a `:dev` image is broken, the fix is the next push, not a rollback.
|
||||
- `:latest` — production. Tracks `main`'s tip and moves on every `main` push and every release. What most operators should run.
|
||||
- `:<commit-sha>` — the rollback unit. Every `main` push publishes one, so any production commit is addressable without a release ceremony. Immutable: a given SHA tag is never re-pushed. Pin one if you need a deployment that cannot change under you, and use it to roll back.
|
||||
- `:dev` — the rolling test channel, rebuilt on every push to `dev` and carrying its own freshly-built Android APK. Run this to try something before it ships. It moves constantly, has no per-commit tag, and its only recovery path is forward — if a `:dev` image is broken, the fix is the next push, not a rollback.
|
||||
|
||||
Every `:latest`, `:vYYYY.MM.DD.HHMM` and `:dev` bundles a signed Android APK, so the in-app update channel is always live. All of them are signed with the same key, so a phone can move between the stable and dev channels without uninstalling — point it at a `:dev` server and the in-app updater offers that channel's build.
|
||||
That is the whole tag map. **There are no version-numbered image tags**, and no `:main`. Git and the build's own self-reported version answer "which build is this" — the Settings page shows it, and so does `/healthz`. Release *tags* in git are still `vYYYY.MM.DD.HHMM`; they name a changelog entry and the APK attached to it, not an image.
|
||||
|
||||
Rolling back to `:<commit-sha>` pins the **server code** at that commit — not the server-and-app pair. The Android APK is baked in at image build time, so a SHA image carries whichever app was current when that commit was built, which may be older than what `:latest` bundles now. If both halves matter, check what the image bundles rather than trusting the tag's name.
|
||||
|
||||
Every `:latest`, `:<commit-sha>` and `:dev` bundles a signed Android APK, so the in-app update channel is always live. All are signed with the same key, so a phone can move between the stable and dev channels without uninstalling — point it at a `:dev` server and the in-app updater offers that channel's build.
|
||||
|
||||
The app reports which channel it is on alongside its version, and decides whether an update is available using the build's ordering key rather than its displayed name — the same value Android installs by, so an offer it makes is one the platform will accept.
|
||||
|
||||
Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
||||
|
||||
Releases before 2026-09-10 use the older per-day `:vYYYY.MM.DD` shape. Those tags still exist and still work — they are simply not extended.
|
||||
Releases up to 2026-09-10 also published a `:vYYYY.MM.DD[.HHMM]` image tag. Those images still exist and still work — they are simply not extended.
|
||||
|
||||
## Specs
|
||||
|
||||
@@ -157,7 +164,7 @@ Two concurrent dev processes:
|
||||
|
||||
- Day-to-day work happens on `dev` (or feature branches merged into `dev`).
|
||||
- `main` is **protected** — changes land via PR from `dev`.
|
||||
- Releases are cut by tagging `v*` off `main`; the release workflow builds and pushes the container image to the Gitea registry.
|
||||
- Releases are cut by tagging `v*` off `main`; the release workflow builds the signed APK, attaches it to the release, and refreshes `:latest` around it.
|
||||
|
||||
Task and milestone tracking: Fable (`Minstrel` project, id 12).
|
||||
|
||||
|
||||
@@ -15,10 +15,14 @@ import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -42,6 +46,12 @@ fun AdminQuarantineScreen(
|
||||
viewModel: AdminQuarantineViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.transientMessages.collect { msg ->
|
||||
snackbarHostState.showSnackbar(msg)
|
||||
}
|
||||
}
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -53,6 +63,7 @@ fun AdminQuarantineScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { inner ->
|
||||
PullToRefreshScaffold(
|
||||
onRefresh = { viewModel.refresh().join() },
|
||||
|
||||
+14
-1
@@ -10,10 +10,13 @@ import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -34,6 +37,15 @@ class AdminQuarantineViewModel @Inject constructor(
|
||||
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
|
||||
val uiState: StateFlow<AdminQuarantineUiState> = internal.asStateFlow()
|
||||
|
||||
/**
|
||||
* One-shot messages for the screen's snackbar. A failed action has to say
|
||||
* why: the row quietly reappearing reads as a glitch, and for a Delete
|
||||
* file refused by a read-only library it hides the one thing the
|
||||
* operator can fix (#3918).
|
||||
*/
|
||||
private val transientMessagesChannel = Channel<String>(Channel.BUFFERED)
|
||||
val transientMessages: Flow<String> = transientMessagesChannel.receiveAsFlow()
|
||||
|
||||
init {
|
||||
refresh()
|
||||
viewModelScope.launch {
|
||||
@@ -86,8 +98,9 @@ class AdminQuarantineViewModel @Inject constructor(
|
||||
try {
|
||||
action(trackId)
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
transientMessagesChannel.trySend(ErrorCopy.fromThrowable(e))
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,18 +37,35 @@ object ErrorCopy {
|
||||
* as connection failures.
|
||||
*/
|
||||
fun fromThrowable(t: Throwable): String = when (t) {
|
||||
is HttpException -> messageFor(codeFromHttp(t))
|
||||
is HttpException -> fromHttp(t)
|
||||
is IOException -> messageFor("connection_refused")
|
||||
else -> TABLE.getValue("unknown")
|
||||
}
|
||||
|
||||
private fun codeFromHttp(e: HttpException): String {
|
||||
/**
|
||||
* Codes whose server message carries specifics the operator needs in
|
||||
* order to act — which directory, which uid — that fixed copy cannot say.
|
||||
* For these the message follows the copy (#3918). Kept to a named set on
|
||||
* purpose: most server messages are internal detail. Mirrors web's
|
||||
* errors.ts.
|
||||
*/
|
||||
private val DETAIL_CODES = setOf("library_not_writable", "file_delete_failed")
|
||||
|
||||
private fun fromHttp(e: HttpException): String {
|
||||
val body = bodyFromHttp(e)
|
||||
val copy = messageFor(body.code.ifEmpty { "unknown" })
|
||||
return if (body.code in DETAIL_CODES && body.message.isNotBlank()) {
|
||||
"$copy ${body.message}"
|
||||
} else {
|
||||
copy
|
||||
}
|
||||
}
|
||||
|
||||
private fun bodyFromHttp(e: HttpException): Body {
|
||||
val raw = runCatching { e.response()?.errorBody()?.string() }.getOrNull()
|
||||
?: return "unknown"
|
||||
val code = runCatching { json.decodeFromString<Envelope>(raw).error?.code }
|
||||
.getOrNull()
|
||||
.orEmpty()
|
||||
return code.ifEmpty { "unknown" }
|
||||
?: return Body()
|
||||
return runCatching { json.decodeFromString<Envelope>(raw).error }
|
||||
.getOrNull() ?: Body()
|
||||
}
|
||||
|
||||
private val TABLE: Map<String, String> = mapOf(
|
||||
@@ -99,6 +116,8 @@ object ErrorCopy {
|
||||
"request_not_pending" to "This request is no longer pending.",
|
||||
"request_not_found" to "That request no longer exists.",
|
||||
"track_not_found" to "That track no longer exists.",
|
||||
"library_not_writable" to "The music library isn't writable by the server.",
|
||||
"file_delete_failed" to "The file couldn't be deleted.",
|
||||
"album_not_found" to "That album no longer exists.",
|
||||
"artist_not_found" to "That artist no longer exists.",
|
||||
"playlist_not_found" to "That playlist no longer exists.",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.fabledsword.minstrel.api
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
|
||||
class ErrorCopyTest {
|
||||
private fun httpError(status: Int, body: String): HttpException =
|
||||
HttpException(
|
||||
Response.error<Unit>(status, body.toResponseBody("application/json".toMediaType())),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun libraryNotWritableAppendsTheServerDetail() {
|
||||
val detail = "Minstrel runs as uid 1000, gid 1000 and cannot delete from /music/A " +
|
||||
"(read-only file system). The library mount must be writable by that user. " +
|
||||
"Nothing was deleted."
|
||||
val e = httpError(409, """{"error":{"code":"library_not_writable","message":"$detail"}}""")
|
||||
|
||||
assertEquals(
|
||||
"${ErrorCopy.messageFor("library_not_writable")} $detail",
|
||||
ErrorCopy.fromThrowable(e),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun detailCodeWithoutAMessageShowsTheCopyAlone() {
|
||||
val e = httpError(409, """{"error":{"code":"library_not_writable","message":""}}""")
|
||||
|
||||
assertEquals(ErrorCopy.messageFor("library_not_writable"), ErrorCopy.fromThrowable(e))
|
||||
}
|
||||
|
||||
// Server messages are usually internal detail; appending them for every
|
||||
// code would leak driver errors into snackbars. This pins the scope.
|
||||
@Test
|
||||
fun otherCodesNeverCarryTheServerMessage() {
|
||||
val e = httpError(404, """{"error":{"code":"track_not_found","message":"pgx: no rows"}}""")
|
||||
|
||||
assertEquals(ErrorCopy.messageFor("track_not_found"), ErrorCopy.fromThrowable(e))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnparseableBodyFallsBackToUnknown() {
|
||||
val e = httpError(500, "not json")
|
||||
|
||||
assertEquals(ErrorCopy.messageFor("unknown"), ErrorCopy.fromThrowable(e))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transportFailureMapsToConnectionRefused() {
|
||||
assertEquals(
|
||||
ErrorCopy.messageFor("connection_refused"),
|
||||
ErrorCopy.fromThrowable(IOException("refused")),
|
||||
)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
+20
-47
@@ -62,57 +62,30 @@ None.
|
||||
- **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.
|
||||
- **Artifacts — use the mirrored actions, never `actions/{upload,download}-artifact`.**
|
||||
- **Artifacts — stock `actions/upload-artifact@v7` and `actions/download-artifact@v8`; never `@v3`.**
|
||||
```yaml
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
```
|
||||
Upstream's `@v4+` cannot work against this instance and no server-side change
|
||||
will help: `isGhes()` rejects any hostname that isn't `github.com` /
|
||||
`*.ghe.com` / `*.localhost` and throws before it opens a connection, so the
|
||||
server is never asked what it supports. `@v3` is worse — it reports success,
|
||||
and Gitea then serves artifacts back only through the v4 API
|
||||
(`content_encoding = application/zip`), so a v3 upload is stored but invisible
|
||||
to every retrieval path. A green job producing nothing retrievable; that is how
|
||||
72 unreachable artifacts accumulated on this repo. Scribe issues 2255 / 2270.
|
||||
Stock works on this forge since the runner moved to gitea/runner 3.x, which
|
||||
edits the actions' client-side `isGhes()` refusal out of their bundles. Proven
|
||||
on 2026-09-10 for upload v4–v7 and download v4–v8 (Scribe spike #3843). Until
|
||||
then this repo pinned SHA mirrors of the Forgejo project's forks, because
|
||||
upstream threw on the hostname before it opened a connection (Scribe 2255).
|
||||
|
||||
Both are pull mirrors of the Forgejo project's forks
|
||||
(`code.forgejo.org/forgejo/{upload,download}-artifact`, one commit on upstream
|
||||
disabling that check), mirrored so CI depends on commits we hold and pinned by
|
||||
SHA because the mirrors auto-sync every 8h — a moved upstream tag would
|
||||
otherwise silently change what runs.
|
||||
`@v3` is still broken: it reports success, and Gitea serves artifacts back only
|
||||
through the v4 API (`content_encoding = application/zip`), so a v3 upload is
|
||||
stored but invisible to every retrieval path. That is how 72 unreachable
|
||||
artifacts accumulated on this repo (Scribe 2270).
|
||||
|
||||
**Match the pins on `@actions/artifact`, not on the actions' own version
|
||||
numbers.** The two actions release on unrelated cadences, so equal version
|
||||
numbers do NOT mean a compatible pair — upload `v5` bundles `@actions/artifact`
|
||||
^4.0.0 while download `v5` bundles ^2.3.2. The pins above are upload **v5** and
|
||||
download **v6**, which is the pairing that puts ^4.0.0 on both sides. This
|
||||
matters because `release.yml` is a producer/consumer pair — `android-release`
|
||||
uploads `minstrel-apk`, `image-release` downloads it — and a protocol mismatch
|
||||
across it yields an empty listing rather than an error, exactly the silent
|
||||
failure this entry exists to prevent.
|
||||
|
||||
| tag | `@actions/artifact` | runtime |
|
||||
|---|---|---|
|
||||
| upload v4 | ^2.1.1 | node20 |
|
||||
| **upload v5** ← pinned | **^4.0.0** | node20 |
|
||||
| download v4 | ^2.1.1 | node20 |
|
||||
| download v5 | ^2.3.2 | node20 |
|
||||
| **download v6** ← pinned | **^4.0.0** | node20 |
|
||||
| download v7 | ^5.0.0 | **node24** |
|
||||
|
||||
The only true protocol break in this history was **v3 → v4** (upstream:
|
||||
"Downloading artifacts that were created from `actions/upload-artifact@v3` and
|
||||
below are not supported"); v4-and-up are one family. Later majors are mostly
|
||||
ergonomics and runtime — upload v4 forbids re-uploading a name and caps a job
|
||||
at 500 artifacts; download v5 made by-ID extraction match by-name.
|
||||
|
||||
**Do not jump the download pin to v7.** That major is a runner requirement, not
|
||||
a feature change: it moves to `runs.using: node24` and upstream states it
|
||||
"requires a minimum Actions Runner version of 2.327.1 … if you are using
|
||||
self-hosted runners, ensure they are updated before upgrading." act_runner is
|
||||
not GitHub's runner and makes no such version claim, so node24 is unverified
|
||||
here. Everything currently pinned is node20.
|
||||
**Pairing no longer needs managing.** This entry used to pin upload v5 against
|
||||
download v6 so both bundled `@actions/artifact` ^4.0.0, warning that a mismatch
|
||||
across `release.yml`'s producer/consumer pair would list empty. Tested, and not
|
||||
true on this instance: every download major v4–v8 read the artifacts of every
|
||||
upload major v4–v7, by name and by pattern (CI-runner run 6312). The only real
|
||||
protocol break is v3 → v4. node24 is no longer a concern either — every
|
||||
CI-runner image carries Node 24 and the runner runs actions with the image's
|
||||
`node`.
|
||||
|
||||
Upload steps set `if-no-files-found: error` rather than the default `warn`, so
|
||||
an upload that matches nothing fails its own job instead of failing the
|
||||
|
||||
+77
-2
@@ -2,7 +2,8 @@
|
||||
#
|
||||
# Derives the three values a build is stamped with, and the tag that names it.
|
||||
#
|
||||
# name=YYYY.MM.DD.HHMM label for people, from the COMMIT's timestamp
|
||||
# name=YYYY.MM.DD.HHMM label for people, from the timestamp of the newest
|
||||
# commit that CHANGED SOMETHING SHIPPED (see SHIPPED)
|
||||
# code=<int> ordering key, minutes since 2020-01-01 at BUILD time
|
||||
# tag=v<name> what a release of this commit must be called
|
||||
#
|
||||
@@ -33,9 +34,83 @@ readonly REF="${1:-HEAD}"
|
||||
|
||||
# Both clocks are overridable so a test can pin them. Nothing but tests should
|
||||
# set these — the defaults are the real derivation.
|
||||
# The paths that do NOT ship, in either artifact. Everything else counts.
|
||||
#
|
||||
# A DENYLIST, and the direction is the whole point. As an allowlist, the list
|
||||
# has to be updated by whoever adds a directory and nothing fails if they
|
||||
# don't — so the failure mode is a changed artifact keeping its old version,
|
||||
# silently, on a green run. That is a build lying about what it is. Inverted,
|
||||
# new content counts by default and the only way to wrongly EXCLUDE something
|
||||
# is to name it here deliberately.
|
||||
#
|
||||
# The two error directions are not symmetric, which is why this is not taste:
|
||||
# wrongly excluded → changed artifact, unchanged version. A silent lie.
|
||||
# wrongly included → version moves when nothing shipped. Cosmetic noise in
|
||||
# a string nobody sorts.
|
||||
#
|
||||
# THIS REPO SHIPS TWO ARTIFACTS FROM ONE DERIVATION, and that is why the list
|
||||
# is shorter than it looks like it should be. The server image ships cmd/,
|
||||
# internal/, shared/, web/, config.example.yaml and client/; the APK ships
|
||||
# android/. Neither ships the other's sources — but excluding android/ here
|
||||
# would stop an Android-only commit from moving the APK's OWN version, which
|
||||
# is the dangerous direction. So this is the union: exclude only what ships in
|
||||
# NEITHER, and accept that an Android commit also nudges the server's reported
|
||||
# version. Over-inclusion across the two, which is the harmless direction.
|
||||
#
|
||||
# The family's other repos (roundtable / roundtable-android) each keep a
|
||||
# tighter list because they are separate repos with one artifact apiece. Do
|
||||
# not copy theirs onto this one.
|
||||
readonly SHIPPED=(
|
||||
.
|
||||
':!.gitea' # CI workflows — including this script's own caller
|
||||
':!ci' # CI scripts — including this script
|
||||
':!docs'
|
||||
':!tools' # asset/font generators; their OUTPUT ships, they do not
|
||||
':!deploy' # test-database bootstrap SQL
|
||||
':!bin' # local `make build` output
|
||||
':!*.md'
|
||||
':!Makefile'
|
||||
':!docker-compose.yml'
|
||||
':!.env.example'
|
||||
':!.gitignore'
|
||||
':!.dockerignore'
|
||||
':!renovate.json'
|
||||
':!.golangci.yml'
|
||||
|
||||
# TESTS DO NOT SHIP, so they must not re-version an artifact.
|
||||
#
|
||||
# Named as globs rather than a directory because this repo has no tests/
|
||||
# tree to exclude: Go tests sit inline beside the code they cover, and the
|
||||
# web suite sits beside its modules. `go build` drops *_test.go outright and
|
||||
# the Vite build never imports a .test.ts, so neither reaches an artifact.
|
||||
#
|
||||
# A commit touching a test AND its source still moves the version — the
|
||||
# source path matches on its own. Only a test-ONLY commit is inert, which is
|
||||
# the whole intent.
|
||||
#
|
||||
# Patterns match what exists today and nothing speculative: there are no
|
||||
# .spec.* files, no __tests__/ directories and no androidTest/ tree. If any
|
||||
# appear they will re-version until named here, which is the harmless
|
||||
# direction and the reason this list is a denylist.
|
||||
':!*_test.go' # 158 files, inline beside the code
|
||||
':!*.test.ts' # 114 files
|
||||
':!*.test.js'
|
||||
':!android/app/src/test' # JVM unit tests; no androidTest tree exists
|
||||
':!web/vitest.config.ts' # test-harness config, not build config
|
||||
':!web/vitest.setup.ts'
|
||||
)
|
||||
|
||||
commit_epoch="${MINSTREL_COMMIT_EPOCH:-}"
|
||||
if [ -z "${commit_epoch}" ]; then
|
||||
commit_epoch="$(git log --format=%ct -1 "${REF}")"
|
||||
commit_epoch="$(git log --format=%ct -1 "${REF}" -- "${SHIPPED[@]}")"
|
||||
# Loudly, on purpose. A silent fallback here is the landmine this whole
|
||||
# script exists to avoid: a plausible-looking version that is quietly wrong,
|
||||
# on a green run. Realistically this means a shallow clone (no commit in
|
||||
# range touches the shipped set) rather than a repo of pure CI config.
|
||||
if [ -z "${commit_epoch}" ]; then
|
||||
echo "version.sh: no commit under '${REF}' touches the shipped file set — shallow clone? (needs fetch-depth: 0)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
now_epoch="${MINSTREL_NOW_EPOCH:-$(date -u +%s)}"
|
||||
|
||||
|
||||
@@ -133,6 +133,13 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
action, err := h.lidarrQuarantine.DeleteFile(r.Context(), id, admin.ID)
|
||||
if err != nil {
|
||||
// Written in the enveloped shape, not writeAdminJSONErr's bare code: the
|
||||
// message is the part that tells the operator which directory and uid.
|
||||
if apiErr, ok := fileRemoveAPIError(err); ok {
|
||||
logFileRemoveFailure(h.logger, apiErr, "track_id", uuidToString(id))
|
||||
writeErr(w, apiErr)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
|
||||
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
|
||||
|
||||
@@ -69,7 +69,7 @@ func installQuarantineClientFn(t *testing.T, h *handlers) {
|
||||
}
|
||||
return lidarr.NewClient(c.BaseURL, c.APIKey)
|
||||
}
|
||||
h.lidarrQuarantine = lidarrquarantine.NewService(h.pool, cfg, clientFn)
|
||||
h.lidarrQuarantine = lidarrquarantine.NewService(h.pool, cfg, clientFn, h.dataDir)
|
||||
}
|
||||
|
||||
// flagDirect bypasses the HTTP handler to seed a quarantine row via the
|
||||
|
||||
@@ -97,14 +97,16 @@ type tuningSnapshot struct {
|
||||
func (h *handlers) tuningSnapshot() tuningSnapshot {
|
||||
var out tuningSnapshot
|
||||
out.Profiles = map[string]weightsResp{
|
||||
recsettings.ScopeRadio: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeRadio)),
|
||||
recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)),
|
||||
recsettings.ScopeRadio: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeRadio)),
|
||||
recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)),
|
||||
recsettings.ScopeSongsLike: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeSongsLike)),
|
||||
}
|
||||
out.Taste = tasteRespFrom(h.recSettings.Taste())
|
||||
out.Discover = discoverRespFrom(h.recSettings.Discover())
|
||||
out.Shipped.Profiles = map[string]weightsResp{
|
||||
recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()),
|
||||
recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
|
||||
recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()),
|
||||
recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
|
||||
recsettings.ScopeSongsLike: weightsRespFrom(recsettings.ShippedSongsLikeWeights()),
|
||||
}
|
||||
out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning())
|
||||
out.Shipped.Discover = discoverRespFrom(recsettings.ShippedDiscoverTuning())
|
||||
|
||||
@@ -23,15 +23,17 @@ type removeTrackResponse struct {
|
||||
|
||||
// handleRemoveTrack implements DELETE /api/admin/tracks/{id}?unmonitor=true|false.
|
||||
//
|
||||
// Admin-only (gated by auth.RequireAdmin on the /admin route group). Always
|
||||
// deletes the file + DB row and runs the album/artist cascade tidy-up. When
|
||||
// Admin-only (gated by auth.RequireAdmin on the /admin route group). Deletes the
|
||||
// file, then the DB row, and runs the album/artist cascade tidy-up — and deletes
|
||||
// nothing at all when the file cannot be removed (#3918). When
|
||||
// unmonitor=true and the track has an mbid, also calls Lidarr.UnmonitorTrack
|
||||
// — failure there is non-fatal (the destructive part already completed) and
|
||||
// surfaces as `lidarr_unmonitor_failed: true` in the success envelope.
|
||||
//
|
||||
// Per spec §5, Lidarr-side errors during the unmonitor step do NOT map to
|
||||
// wire error codes; the only error codes this handler emits are not_found,
|
||||
// server_error, plus the auth codes the middleware emits upstream.
|
||||
// wire error codes. The codes this handler emits are not_found,
|
||||
// library_not_writable (409) and file_delete_failed when the file could not be
|
||||
// removed, server_error, plus the auth codes the middleware emits upstream.
|
||||
func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := chi.URLParam(r, "id")
|
||||
trackID, ok := parseUUID(idStr)
|
||||
@@ -66,6 +68,11 @@ func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "track not found"})
|
||||
return
|
||||
}
|
||||
if apiErr, ok := fileRemoveAPIError(err); ok {
|
||||
logFileRemoveFailure(h.logger, apiErr, "track_id", idStr)
|
||||
writeErr(w, apiErr)
|
||||
return
|
||||
}
|
||||
h.logger.Error("api: remove track failed", "err", err, "track_id", idStr)
|
||||
writeErr(w, apierror.InternalMsg("remove failed", err))
|
||||
return
|
||||
|
||||
+12
-6
@@ -24,6 +24,7 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||
@@ -55,6 +56,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
streamSecret: streamSecret,
|
||||
netSettings: netSettings,
|
||||
reacqSettings: reacqSettings,
|
||||
librarySize: recommendation.NewLibrarySize(nil),
|
||||
}
|
||||
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
@@ -268,12 +270,16 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
}
|
||||
|
||||
type handlers struct {
|
||||
pool *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
events *playevents.Writer
|
||||
recCfg config.RecommendationConfig
|
||||
recSettings *recsettings.Service
|
||||
rng func() float64
|
||||
pool *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
events *playevents.Writer
|
||||
recCfg config.RecommendationConfig
|
||||
recSettings *recsettings.Service
|
||||
rng func() float64
|
||||
// librarySize memoises the track count that sizes the candidate pool
|
||||
// (#3880). Held here rather than counted per request: the count is a
|
||||
// full table scan, and library size only moves when a scan runs.
|
||||
librarySize *recommendation.LibrarySize
|
||||
lidarrCfg *lidarrconfig.Service
|
||||
lidarrRequests *lidarrrequests.Service
|
||||
lidarrQuarantine *lidarrquarantine.Service
|
||||
|
||||
@@ -65,7 +65,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
||||
}
|
||||
lidarrCfg := lidarrconfig.New(pool)
|
||||
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
|
||||
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil)
|
||||
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil, "")
|
||||
// tracks.Service has no Lidarr unmonitorer in tests by default; the
|
||||
// admin-tracks tests below override h.tracks via installTracksLidarrStub
|
||||
// when they need a stubbed Lidarr.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
)
|
||||
|
||||
// fileRemoveAPIError answers a delete that could not reach the track's file
|
||||
// (#3918). Both delete endpoints use it, so the operator gets the same
|
||||
// explanation from the admin remove-track action and from quarantine's Delete
|
||||
// file.
|
||||
//
|
||||
// The unwritable case is a 409 rather than a 500 because nothing is broken: the
|
||||
// request conflicts with how the library is mounted, and the fix is the
|
||||
// operator's. The message names the directory — removal writes to the parent,
|
||||
// not the file — and the uid/gid the process runs as, which is the half of a
|
||||
// permission problem invisible from the host. Every case says nothing was
|
||||
// deleted, because that is exactly what the operator will be worried about.
|
||||
func fileRemoveAPIError(err error) (*apierror.Error, bool) {
|
||||
var fre *library.FileRemoveError
|
||||
if !errors.As(err, &fre) {
|
||||
return nil, false
|
||||
}
|
||||
if fre.NotWritable() {
|
||||
return &apierror.Error{
|
||||
Status: http.StatusConflict,
|
||||
Code: "library_not_writable",
|
||||
Message: fmt.Sprintf(
|
||||
"Minstrel runs as uid %d, gid %d and cannot delete from %s (%s). "+
|
||||
"The library mount must be writable by that user. Nothing was deleted.",
|
||||
fre.UID, fre.GID, fre.Dir(), fre.Reason()),
|
||||
Cause: err,
|
||||
}, true
|
||||
}
|
||||
return &apierror.Error{
|
||||
Status: http.StatusInternalServerError,
|
||||
Code: "file_delete_failed",
|
||||
Message: fmt.Sprintf("Could not delete %s (%s). Nothing was deleted.", fre.Path, fre.Reason()),
|
||||
Cause: err,
|
||||
}, true
|
||||
}
|
||||
|
||||
// logFileRemoveFailure records a delete that could not reach its file. An
|
||||
// unwritable library is an environment fact the operator can fix, so it is a
|
||||
// Warn; anything else is a real fault.
|
||||
func logFileRemoveFailure(logger *slog.Logger, apiErr *apierror.Error, attrs ...any) {
|
||||
attrs = append(attrs, "code", apiErr.Code, "err", apiErr.Cause)
|
||||
if apiErr.Status == http.StatusConflict {
|
||||
logger.Warn("api: track file could not be deleted", attrs...)
|
||||
return
|
||||
}
|
||||
logger.Error("api: track file could not be deleted", attrs...)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
)
|
||||
|
||||
const removeTestPath = "/music/Moe Shop/WWW (2020)/01 - WWW.mp3"
|
||||
|
||||
// removeFailure builds the error a delete service returns when the file would
|
||||
// not go, wrapped the way lidarrquarantine.DeleteFile and tracks.RemoveTrack
|
||||
// wrap it — the mapping has to see through that.
|
||||
func removeFailure(errno syscall.Errno) error {
|
||||
return fmt.Errorf("delete file: %w", &library.FileRemoveError{
|
||||
Path: removeTestPath, UID: 1000, GID: 1000,
|
||||
Err: &fs.PathError{Op: "remove", Path: removeTestPath, Err: errno},
|
||||
})
|
||||
}
|
||||
|
||||
func TestFileRemoveAPIError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
errno syscall.Errno
|
||||
wantStatus int
|
||||
wantCode string
|
||||
wantIn []string
|
||||
}{
|
||||
{
|
||||
name: "read-only mount", errno: syscall.EROFS,
|
||||
wantStatus: http.StatusConflict, wantCode: "library_not_writable",
|
||||
wantIn: []string{"uid 1000, gid 1000", "/music/Moe Shop/WWW (2020)", "read-only file system", "Nothing was deleted"},
|
||||
},
|
||||
{
|
||||
name: "permission denied", errno: syscall.EACCES,
|
||||
wantStatus: http.StatusConflict, wantCode: "library_not_writable",
|
||||
wantIn: []string{"permission denied", "Nothing was deleted"},
|
||||
},
|
||||
{
|
||||
name: "operation not permitted", errno: syscall.EPERM,
|
||||
wantStatus: http.StatusConflict, wantCode: "library_not_writable",
|
||||
wantIn: []string{"operation not permitted"},
|
||||
},
|
||||
{
|
||||
name: "i/o error", errno: syscall.EIO,
|
||||
wantStatus: http.StatusInternalServerError, wantCode: "file_delete_failed",
|
||||
wantIn: []string{removeTestPath, "input/output error", "Nothing was deleted"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
apiErr, ok := fileRemoveAPIError(removeFailure(tc.errno))
|
||||
if !ok {
|
||||
t.Fatal("a wrapped *library.FileRemoveError was not recognised")
|
||||
}
|
||||
if apiErr.Status != tc.wantStatus || apiErr.Code != tc.wantCode {
|
||||
t.Fatalf("got %d %s, want %d %s", apiErr.Status, apiErr.Code, tc.wantStatus, tc.wantCode)
|
||||
}
|
||||
for _, want := range tc.wantIn {
|
||||
if !strings.Contains(apiErr.Message, want) {
|
||||
t.Errorf("message %q lacks %q", apiErr.Message, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The unwritable answer must name the DIRECTORY. Removal needs write access to
|
||||
// the parent, so a message naming the file would send the operator to fix the
|
||||
// wrong permissions. The directory is a prefix of the file path, which is why a
|
||||
// plain "contains the directory" check could never catch that regression.
|
||||
func TestFileRemoveAPIError_NotWritableNamesTheDirectoryNotTheFile(t *testing.T) {
|
||||
apiErr, _ := fileRemoveAPIError(removeFailure(syscall.EROFS))
|
||||
if strings.Contains(apiErr.Message, "01 - WWW.mp3") {
|
||||
t.Fatalf("message names the file rather than its directory: %q", apiErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileRemoveAPIError_IgnoresOtherErrors(t *testing.T) {
|
||||
for name, err := range map[string]error{
|
||||
"nil": nil,
|
||||
"plain error": errors.New("delete track: connection reset"),
|
||||
"path error": &fs.PathError{Op: "remove", Path: removeTestPath, Err: syscall.EROFS},
|
||||
"not found": library.ErrTrackNotFound,
|
||||
} {
|
||||
if _, ok := fileRemoveAPIError(err); ok {
|
||||
t.Errorf("%s: mapped as a file-remove failure", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-2
@@ -87,10 +87,24 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
||||
currentVec.DeviceClass = latestDeviceClass(r.Context(), q, user.ID, h.logger)
|
||||
|
||||
exclude := parseExcludeParam(r.URL.Query().Get("exclude"))
|
||||
limits := recommendation.DefaultCandidateSourceLimits()
|
||||
// Size the pool to the library (#3880). A fixed ~170 candidates samples a
|
||||
// shrinking fraction of a growing collection, which is what made the
|
||||
// recommendations feel less relevant as the library grew. Degrades to the
|
||||
// base limits if the count is unavailable — never fails the request over a
|
||||
// sizing hint.
|
||||
librarySize := h.librarySize.Get(r.Context(), func(ctx context.Context) (int64, error) {
|
||||
return recommendation.CountLibraryTracks(ctx, q)
|
||||
})
|
||||
limits := recommendation.ScaleForLibrary(
|
||||
recommendation.DefaultCandidateSourceLimits(), librarySize,
|
||||
)
|
||||
candidates, err := recommendation.LoadCandidatesFromSimilarity(
|
||||
r.Context(), q, user.ID, seedID,
|
||||
h.recCfg.RecentlyPlayedHours, currentVec, exclude, limits,
|
||||
// A fresh seed per request (#3889): radio is a new session each time
|
||||
// and SHOULD draw differently. The system mixes are the surfaces that
|
||||
// promise repeatability; this is not one of them.
|
||||
strconv.FormatInt(time.Now().UnixNano(), 36),
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Warn("api: radio: similarity-pool failed; falling back to whole-library", "err", err)
|
||||
@@ -108,7 +122,15 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
||||
// Scoring weights come from the DB-backed tuning lab (#1250) —
|
||||
// read per request so an admin change takes effect live.
|
||||
weights := h.recSettings.Weights(recsettings.ScopeRadio)
|
||||
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
|
||||
// Diversity caps (#3882). Radio had none while every sibling surface did,
|
||||
// which is how a whole session could come back from one artist. Scaled to
|
||||
// the requested length so a 20-track radio and a 200-track one are capped
|
||||
// alike; Shuffle relaxes them rather than returning a short radio.
|
||||
//
|
||||
// limit-1 because the seed track occupies the first slot and is prepended
|
||||
// below — the caps govern the tracks that FOLLOW it.
|
||||
caps := recommendation.RadioDiversityCaps(limit - 1)
|
||||
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1, caps)
|
||||
|
||||
out := make([]TrackRef, 0, len(picks)+1)
|
||||
out = append(out, trackRefFrom(track, album.Title, artist.Name))
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: fingerprints.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const deleteTrackFingerprint = `-- name: DeleteTrackFingerprint :exec
|
||||
DELETE FROM track_fingerprints WHERE track_id = $1
|
||||
`
|
||||
|
||||
// A file changed but could not be fingerprinted, for a reason unrelated to the
|
||||
// file. The stored row describes the OLD bytes, so it goes and the backfill
|
||||
// re-derives it — nothing may keep trusting a stale identity.
|
||||
func (q *Queries) DeleteTrackFingerprint(ctx context.Context, trackID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteTrackFingerprint, trackID)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertTrackFingerprint = `-- name: UpsertTrackFingerprint :exec
|
||||
INSERT INTO track_fingerprints (
|
||||
track_id, audio_stream_sha256, chromaprint, fingerprint_version
|
||||
) VALUES (
|
||||
$1, $2, $3,
|
||||
$4
|
||||
)
|
||||
ON CONFLICT (track_id) DO UPDATE SET
|
||||
audio_stream_sha256 = EXCLUDED.audio_stream_sha256,
|
||||
chromaprint = EXCLUDED.chromaprint,
|
||||
fingerprint_version = EXCLUDED.fingerprint_version,
|
||||
computed_at = now()
|
||||
`
|
||||
|
||||
type UpsertTrackFingerprintParams struct {
|
||||
TrackID pgtype.UUID
|
||||
AudioStreamSha256 []byte
|
||||
Chromaprint []int32
|
||||
FingerprintVersion int16
|
||||
}
|
||||
|
||||
// Written whenever a track's fingerprint is derived: by the scan when a file is
|
||||
// new or its bytes changed, and by the backfill (#3908) for rows derived by an
|
||||
// older method. Replaces the row wholesale — a fingerprint of the old bytes has
|
||||
// no standing once the file has changed.
|
||||
func (q *Queries) UpsertTrackFingerprint(ctx context.Context, arg UpsertTrackFingerprintParams) error {
|
||||
_, err := q.db.Exec(ctx, upsertTrackFingerprint,
|
||||
arg.TrackID,
|
||||
arg.AudioStreamSha256,
|
||||
arg.Chromaprint,
|
||||
arg.FingerprintVersion,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -667,6 +667,14 @@ type Track struct {
|
||||
MissingSince pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type TrackFingerprint struct {
|
||||
TrackID pgtype.UUID
|
||||
AudioStreamSha256 []byte
|
||||
Chromaprint []int32
|
||||
FingerprintVersion int16
|
||||
ComputedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type TrackSimilarity struct {
|
||||
TrackAID pgtype.UUID
|
||||
TrackBID pgtype.UUID
|
||||
|
||||
@@ -829,7 +829,7 @@ similar_artists AS (
|
||||
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
|
||||
WHERE asim.source = 'listenbrainz'
|
||||
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||||
ORDER BY asim.score DESC, random()
|
||||
ORDER BY asim.score DESC, md5(t.id::text || $12::text)
|
||||
LIMIT $6
|
||||
),
|
||||
tag_overlap AS (
|
||||
@@ -857,7 +857,7 @@ likes_overlap AS (
|
||||
WHERE t.id = gl.track_id
|
||||
AND trim(g_overlap.g) IN (SELECT tag FROM seed_tags)
|
||||
)
|
||||
ORDER BY random()
|
||||
ORDER BY md5(gl.track_id::text || $12::text)
|
||||
LIMIT $8
|
||||
),
|
||||
taste_overlap AS (
|
||||
@@ -884,7 +884,7 @@ coplay_artists AS (
|
||||
WHERE asim.source = 'user_cooccurrence'
|
||||
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||||
AND t.id <> $2
|
||||
ORDER BY asim.score DESC, random()
|
||||
ORDER BY asim.score DESC, md5(t.id::text || $12::text)
|
||||
LIMIT $11
|
||||
),
|
||||
random_fill AS (
|
||||
@@ -900,7 +900,7 @@ random_fill AS (
|
||||
UNION SELECT track_id FROM taste_overlap
|
||||
UNION SELECT track_id FROM coplay_artists
|
||||
)
|
||||
ORDER BY random()
|
||||
ORDER BY md5(t.id::text || $12::text)
|
||||
LIMIT $9
|
||||
)
|
||||
SELECT
|
||||
@@ -938,17 +938,18 @@ GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
|
||||
`
|
||||
|
||||
type LoadRadioCandidatesV2Params struct {
|
||||
UserID pgtype.UUID
|
||||
ID pgtype.UUID
|
||||
Column3 interface{}
|
||||
Column4 []pgtype.UUID
|
||||
Limit int32
|
||||
Limit_2 int32
|
||||
Limit_3 int32
|
||||
Limit_4 int32
|
||||
Limit_5 int32
|
||||
Limit_6 int32
|
||||
Limit_7 int32
|
||||
UserID pgtype.UUID
|
||||
ID pgtype.UUID
|
||||
Column3 interface{}
|
||||
Column4 []pgtype.UUID
|
||||
Limit int32
|
||||
Limit_2 int32
|
||||
Limit_3 int32
|
||||
Limit_4 int32
|
||||
Limit_5 int32
|
||||
Limit_6 int32
|
||||
Limit_7 int32
|
||||
Column12 string
|
||||
}
|
||||
|
||||
type LoadRadioCandidatesV2Row struct {
|
||||
@@ -971,8 +972,22 @@ type LoadRadioCandidatesV2Row struct {
|
||||
// enter the pool even when the similarity/random arms miss them; scored
|
||||
// in Go via TasteMatch, so sim_score here is 0 pool-inclusion),
|
||||
// $11 coplay_artists K (#1533 — tracks by artists co-played across the
|
||||
// instance with the seed's artist; source='user_cooccurrence').
|
||||
// instance with the seed's artist; source='user_cooccurrence'),
|
||||
// $12 order_seed (text) — see below.
|
||||
//
|
||||
// $12 REPLACES `ORDER BY random()` IN FOUR ARMS (#3889). Those arms returned
|
||||
// a stable set only while their LIMIT exceeded the rows eligible for them: at
|
||||
// that point they returned all of them and the order stopped mattering,
|
||||
// because the caller sorts by track id before scoring. Below that threshold
|
||||
// they returned a random SUBSET, and two builds on the same day drew
|
||||
// different ones — so "daily determinism" held by accident, and only for
|
||||
// libraries smaller than the limits.
|
||||
//
|
||||
// md5(id || seed) keeps the intent — an arbitrary spread that changes when
|
||||
// the seed does — while making it reproducible for a given seed. The CALLER
|
||||
// decides what that means: system mixes pass a per-(user, day) string and get
|
||||
// the determinism they promise; radio passes a fresh value per request and
|
||||
// keeps varying, which is what a radio should do.
|
||||
// Returns same shape as LoadRadioCandidates plus similarity_score column.
|
||||
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
|
||||
rows, err := q.db.Query(ctx, loadRadioCandidatesV2,
|
||||
@@ -987,6 +1002,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
||||
arg.Limit_5,
|
||||
arg.Limit_6,
|
||||
arg.Limit_7,
|
||||
arg.Column12,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Drop the rows the narrower constraints are about to forbid, or re-adding
|
||||
-- them fails against existing data (the 0051 down-migration pattern).
|
||||
DELETE FROM recommendation_weight_profiles WHERE profile = 'songs_like';
|
||||
DELETE FROM recommendation_tuning_audit WHERE scope = 'songs_like';
|
||||
|
||||
ALTER TABLE recommendation_tuning_audit
|
||||
DROP CONSTRAINT recommendation_tuning_audit_scope_check;
|
||||
ALTER TABLE recommendation_tuning_audit
|
||||
ADD CONSTRAINT recommendation_tuning_audit_scope_check
|
||||
CHECK (scope IN ('radio', 'daily_mix', 'taste', 'discover'));
|
||||
|
||||
ALTER TABLE recommendation_weight_profiles
|
||||
DROP CONSTRAINT recommendation_weight_profiles_profile_check;
|
||||
ALTER TABLE recommendation_weight_profiles
|
||||
ADD CONSTRAINT recommendation_weight_profiles_profile_check
|
||||
CHECK (profile IN ('radio', 'daily_mix'));
|
||||
@@ -0,0 +1,37 @@
|
||||
-- 0057_songs_like_tuning.up.sql — a THIRD weight profile, for Songs-like
|
||||
-- (Scribe #3881, milestone #398).
|
||||
--
|
||||
-- Songs-like shared the `daily_mix` profile with For-You, and that is the bug.
|
||||
-- The two surfaces want opposite things: For-You is a broad "what will they
|
||||
-- enjoy today", Songs-like answers "what sounds like THIS", and under one set
|
||||
-- of weights the broad answer wins. Operator, 2026-09-10: "I'm expecting to
|
||||
-- get a consistent sound and style from the experience... I was getting a
|
||||
-- seeming wide variety of music from each one when I was hoping to stay in a
|
||||
-- certain neighborhood."
|
||||
--
|
||||
-- Under the shared daily_mix weights, an UNRELATED track the user had liked
|
||||
-- and not played recently scored 1.0 + 2.0 + 1.0 = 4.0 before taste, while a
|
||||
-- PERFECT similarity match they had not liked scored 1.0 + 1.5 = 2.5. Liking
|
||||
-- something outranked sounding like the seed. Splitting the profile is what
|
||||
-- lets similarity dominate here without making For-You narrow.
|
||||
--
|
||||
-- Rows are seeded by the recsettings boot reconcile, not here, so shipped
|
||||
-- defaults live in exactly one place (Go) — same as 0040.
|
||||
|
||||
-- Rule #36: a new value for a CHECK-gated column needs the constraint
|
||||
-- rewritten in the SAME change, or the first row written under the new
|
||||
-- profile fails at runtime rather than at migrate time.
|
||||
ALTER TABLE recommendation_weight_profiles
|
||||
DROP CONSTRAINT recommendation_weight_profiles_profile_check;
|
||||
ALTER TABLE recommendation_weight_profiles
|
||||
ADD CONSTRAINT recommendation_weight_profiles_profile_check
|
||||
CHECK (profile IN ('radio', 'daily_mix', 'songs_like'));
|
||||
|
||||
-- The audit table gates the same name on a separate constraint. Missing this
|
||||
-- one would let the profile be seeded and then fail on the first knob turn —
|
||||
-- green at boot, 500 on first use.
|
||||
ALTER TABLE recommendation_tuning_audit
|
||||
DROP CONSTRAINT recommendation_tuning_audit_scope_check;
|
||||
ALTER TABLE recommendation_tuning_audit
|
||||
ADD CONSTRAINT recommendation_tuning_audit_scope_check
|
||||
CHECK (scope IN ('radio', 'daily_mix', 'taste', 'discover', 'songs_like'));
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE track_fingerprints;
|
||||
@@ -0,0 +1,38 @@
|
||||
-- 0058_track_fingerprints.up.sql — an acoustic identity per track (Scribe
|
||||
-- milestone #400: #3905, #3906).
|
||||
--
|
||||
-- A table of its own rather than columns on tracks, for the hot path's sake:
|
||||
-- tracks is read with SELECT * by eight queries, among them ListTracksByAlbum,
|
||||
-- SearchTracks and GetTracksByIDs — album pages, search, the Subsonic surface.
|
||||
-- A ~4 KB chromaprint column on tracks would be de-TOASTed on every one of
|
||||
-- those reads to carry a value only the duplicate sweep ever looks at.
|
||||
--
|
||||
-- What a row means, which the backfill depends on:
|
||||
-- no row never fingerprinted
|
||||
-- fingerprint_version < current derived by an older method; re-derive it
|
||||
-- fingerprint_version = current attempted; a NULL value means that tool
|
||||
-- failed on this file, and it is not retried
|
||||
-- until the file changes
|
||||
-- A failure that says nothing about the file — a timeout, a cancelled scan, a
|
||||
-- missing binary — writes no row at all, so the backfill tries again.
|
||||
CREATE TABLE track_fingerprints (
|
||||
-- CASCADE is right here, unlike for the likes and play history M400's
|
||||
-- merge has to carry across: a fingerprint describes one file's bytes and
|
||||
-- means nothing once that file's row is gone.
|
||||
track_id uuid PRIMARY KEY REFERENCES tracks (id) ON DELETE CASCADE,
|
||||
-- SHA-256 of the ENCODED audio packets (ffmpeg -c:a copy -f hash), not of
|
||||
-- decoded samples. internal/library/fingerprint.go says why.
|
||||
audio_stream_sha256 bytea
|
||||
CHECK (audio_stream_sha256 IS NULL OR octet_length(audio_stream_sha256) = 32),
|
||||
-- fpcalc -raw -signed: the same 32 bits per item, stored signed because
|
||||
-- integer is.
|
||||
chromaprint integer[],
|
||||
fingerprint_version smallint NOT NULL,
|
||||
computed_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- The exact duplicate tier is an equality match on this column. Partial
|
||||
-- because a NULL is never looked up — it only means the hash was not taken.
|
||||
CREATE INDEX track_fingerprints_audio_stream_sha256
|
||||
ON track_fingerprints (audio_stream_sha256)
|
||||
WHERE audio_stream_sha256 IS NOT NULL;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- name: UpsertTrackFingerprint :exec
|
||||
-- Written whenever a track's fingerprint is derived: by the scan when a file is
|
||||
-- new or its bytes changed, and by the backfill (#3908) for rows derived by an
|
||||
-- older method. Replaces the row wholesale — a fingerprint of the old bytes has
|
||||
-- no standing once the file has changed.
|
||||
INSERT INTO track_fingerprints (
|
||||
track_id, audio_stream_sha256, chromaprint, fingerprint_version
|
||||
) VALUES (
|
||||
sqlc.arg(track_id), sqlc.narg(audio_stream_sha256), sqlc.narg(chromaprint),
|
||||
sqlc.arg(fingerprint_version)
|
||||
)
|
||||
ON CONFLICT (track_id) DO UPDATE SET
|
||||
audio_stream_sha256 = EXCLUDED.audio_stream_sha256,
|
||||
chromaprint = EXCLUDED.chromaprint,
|
||||
fingerprint_version = EXCLUDED.fingerprint_version,
|
||||
computed_at = now();
|
||||
|
||||
-- name: DeleteTrackFingerprint :exec
|
||||
-- A file changed but could not be fingerprinted, for a reason unrelated to the
|
||||
-- file. The stored row describes the OLD bytes, so it goes and the backfill
|
||||
-- re-derives it — nothing may keep trusting a stale identity.
|
||||
DELETE FROM track_fingerprints WHERE track_id = $1;
|
||||
@@ -45,7 +45,22 @@ WHERE t.id <> $2
|
||||
-- enter the pool even when the similarity/random arms miss them; scored
|
||||
-- in Go via TasteMatch, so sim_score here is 0 pool-inclusion),
|
||||
-- $11 coplay_artists K (#1533 — tracks by artists co-played across the
|
||||
-- instance with the seed's artist; source='user_cooccurrence').
|
||||
-- instance with the seed's artist; source='user_cooccurrence'),
|
||||
-- $12 order_seed (text) — see below.
|
||||
--
|
||||
-- $12 REPLACES `ORDER BY random()` IN FOUR ARMS (#3889). Those arms returned
|
||||
-- a stable set only while their LIMIT exceeded the rows eligible for them: at
|
||||
-- that point they returned all of them and the order stopped mattering,
|
||||
-- because the caller sorts by track id before scoring. Below that threshold
|
||||
-- they returned a random SUBSET, and two builds on the same day drew
|
||||
-- different ones — so "daily determinism" held by accident, and only for
|
||||
-- libraries smaller than the limits.
|
||||
--
|
||||
-- md5(id || seed) keeps the intent — an arbitrary spread that changes when
|
||||
-- the seed does — while making it reproducible for a given seed. The CALLER
|
||||
-- decides what that means: system mixes pass a per-(user, day) string and get
|
||||
-- the determinism they promise; radio passes a fresh value per request and
|
||||
-- keeps varying, which is what a radio should do.
|
||||
-- Returns same shape as LoadRadioCandidates plus similarity_score column.
|
||||
|
||||
WITH
|
||||
@@ -87,7 +102,7 @@ similar_artists AS (
|
||||
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
|
||||
WHERE asim.source = 'listenbrainz'
|
||||
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||||
ORDER BY asim.score DESC, random()
|
||||
ORDER BY asim.score DESC, md5(t.id::text || $12::text)
|
||||
LIMIT $6
|
||||
),
|
||||
tag_overlap AS (
|
||||
@@ -115,7 +130,7 @@ likes_overlap AS (
|
||||
WHERE t.id = gl.track_id
|
||||
AND trim(g_overlap.g) IN (SELECT tag FROM seed_tags)
|
||||
)
|
||||
ORDER BY random()
|
||||
ORDER BY md5(gl.track_id::text || $12::text)
|
||||
LIMIT $8
|
||||
),
|
||||
taste_overlap AS (
|
||||
@@ -142,7 +157,7 @@ coplay_artists AS (
|
||||
WHERE asim.source = 'user_cooccurrence'
|
||||
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||||
AND t.id <> $2
|
||||
ORDER BY asim.score DESC, random()
|
||||
ORDER BY asim.score DESC, md5(t.id::text || $12::text)
|
||||
LIMIT $11
|
||||
),
|
||||
random_fill AS (
|
||||
@@ -158,7 +173,7 @@ random_fill AS (
|
||||
UNION SELECT track_id FROM taste_overlap
|
||||
UNION SELECT track_id FROM coplay_artists
|
||||
)
|
||||
ORDER BY random()
|
||||
ORDER BY md5(t.id::text || $12::text)
|
||||
LIMIT $9
|
||||
)
|
||||
SELECT
|
||||
|
||||
@@ -87,6 +87,7 @@ var dataTables = []string{
|
||||
// pristine Discover knobs rather than whatever a previous test tuned.
|
||||
"discover_tuning",
|
||||
"recommendation_tuning_audit",
|
||||
"track_fingerprints", // M400
|
||||
"tracks",
|
||||
"albums",
|
||||
"artists",
|
||||
|
||||
+142
-33
@@ -5,12 +5,16 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||
)
|
||||
@@ -19,54 +23,159 @@ import (
|
||||
// that has no row in tracks.
|
||||
var ErrTrackNotFound = errors.New("library: track not found")
|
||||
|
||||
// DeleteTrackFile removes a track file from disk and its row from the
|
||||
// tracks table. Album and artist rows are left untouched.
|
||||
// removeFile is os.Remove behind a variable so a test can make removal fail the
|
||||
// way a read-only mount or a wrongly-owned directory does. A chmod-based test
|
||||
// cannot stand in for that: root ignores permission bits, so in a CI container
|
||||
// running as root it would pass without ever exercising the failure.
|
||||
var removeFile = os.Remove
|
||||
|
||||
// FileRemoveError reports that a track's file exists but could not be removed.
|
||||
// When DeleteTrackFile returns one, NOTHING was deleted: the row, its likes, its
|
||||
// play history and its playlist memberships are all intact.
|
||||
type FileRemoveError struct {
|
||||
Path string
|
||||
// UID and GID are the identity the server process runs as — the half of a
|
||||
// permission problem the operator cannot see from the host side.
|
||||
UID, GID int
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *FileRemoveError) Error() string { return fmt.Sprintf("remove track file: %v", e.Err) }
|
||||
|
||||
func (e *FileRemoveError) Unwrap() error { return e.Err }
|
||||
|
||||
// Dir is the directory removal needs write access to. Unlinking a file writes to
|
||||
// its PARENT, so a world-writable file inside a read-only directory still cannot
|
||||
// be removed — naming the file's own permissions would send the operator to the
|
||||
// wrong place.
|
||||
func (e *FileRemoveError) Dir() string { return filepath.Dir(e.Path) }
|
||||
|
||||
// NotWritable reports whether the library is unwritable for this process — a
|
||||
// read-only mount or a permission denial — rather than an I/O fault. It is the
|
||||
// case the operator can fix, so callers answer it differently.
|
||||
func (e *FileRemoveError) NotWritable() bool {
|
||||
return errors.Is(e.Err, fs.ErrPermission) || errors.Is(e.Err, syscall.EROFS)
|
||||
}
|
||||
|
||||
// Reason is the underlying cause without the path os.Remove already wrapped
|
||||
// around it, for messages that name the directory themselves.
|
||||
func (e *FileRemoveError) Reason() string {
|
||||
var pathErr *fs.PathError
|
||||
if errors.As(e.Err, &pathErr) {
|
||||
return pathErr.Err.Error()
|
||||
}
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
// DeletedTrack reports what a delete tidied away beyond the track itself.
|
||||
type DeletedTrack struct {
|
||||
// AlbumID is set when the track was its album's last, so the album went too.
|
||||
AlbumID *pgtype.UUID
|
||||
// ArtistID is set when that album was its artist's last, so the artist went too.
|
||||
ArtistID *pgtype.UUID
|
||||
}
|
||||
|
||||
// DeleteTrackFile removes a track's file from disk and then its row, tidying
|
||||
// away an album or artist the delete leaves empty. It is the ONLY path that
|
||||
// deletes a track file: the admin remove-track endpoint and quarantine's Delete
|
||||
// file both come through here (#3918).
|
||||
//
|
||||
// Steps:
|
||||
// 1. Look up the track to get its file_path.
|
||||
// 2. Remove the file from disk. fs.ErrNotExist is OK — already gone.
|
||||
// 3. Delete the tracks row.
|
||||
// Order is the whole contract. The file goes first, and if it cannot go — a
|
||||
// read-only mount, a permission denial, an I/O error — nothing else happens and
|
||||
// a *FileRemoveError comes back. Proceeding past that failure is how #3918 lost
|
||||
// history: tracks CASCADEs to play_events, general_likes, contextual_likes,
|
||||
// playlist_tracks, track_tags and playback_errors, so the row and everything
|
||||
// hanging off it were destroyed while the file survived, and the next scan
|
||||
// re-imported it as a brand-new track with none of it.
|
||||
//
|
||||
// Order matters: file first, then DB. If the file delete fails (permission,
|
||||
// I/O error), we leave the DB row alone so the admin can retry.
|
||||
// A file that is already gone (fs.ErrNotExist) is not a failure; the row is
|
||||
// removed as asked.
|
||||
//
|
||||
// The reverse failure mode — file gone, DB row still present — IS reconciled
|
||||
// now, and not by this function: the scan's reconcile pass stamps
|
||||
// tracks.missing_since (#2523), every selection path filters on it, and a file
|
||||
// that returns is un-marked or adopted at its new path (#2528). That is the
|
||||
// normal life of a vanished file and it is deliberately non-destructive: the
|
||||
// row, its play history and its likes survive, because a missing file is a
|
||||
// track Minstrel still knows about (#2527).
|
||||
// This is NOT the missing-file path. That lifecycle is deliberately
|
||||
// non-destructive: reconcile stamps missing_since (#2523), selection paths
|
||||
// filter on it, and a returning file is un-marked or adopted (#2528). This is the
|
||||
// explicit, irreversible "remove this recording", never the way to tidy up a row
|
||||
// whose file merely went away.
|
||||
//
|
||||
// So this function is NOT the missing-file path. It is the explicit admin
|
||||
// action "remove this recording from disk and from the library", and it is
|
||||
// irreversible: tracks CASCADEs to play_events, general_likes_tracks,
|
||||
// contextual_likes, track_tags and playback_errors. Reach for it when the
|
||||
// operator means to destroy the record, never to tidy up a row whose file
|
||||
// merely went away.
|
||||
func DeleteTrackFile(ctx context.Context, pool *pgxpool.Pool, trackID pgtype.UUID) error {
|
||||
// dataDir, when set, also clears the cached art of an artist the delete removed.
|
||||
// logger may be nil.
|
||||
func DeleteTrackFile(
|
||||
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string, trackID pgtype.UUID,
|
||||
) (DeletedTrack, error) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
q := dbq.New(pool)
|
||||
track, err := q.GetTrackByID(ctx, trackID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrTrackNotFound
|
||||
return DeletedTrack{}, ErrTrackNotFound
|
||||
}
|
||||
return fmt.Errorf("get track: %w", err)
|
||||
return DeletedTrack{}, fmt.Errorf("get track: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Remove(track.FilePath); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("remove file: %w", err)
|
||||
if err := removeFile(track.FilePath); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return DeletedTrack{}, &FileRemoveError{
|
||||
Path: track.FilePath, UID: os.Getuid(), GID: os.Getgid(), Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := pool.Exec(ctx, "DELETE FROM tracks WHERE id = $1", trackID); err != nil {
|
||||
return fmt.Errorf("delete row: %w", err)
|
||||
// The row and any album or artist it empties go together, so a failure
|
||||
// partway cannot leave a deleted track with a ghost album behind it.
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return DeletedTrack{}, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
// Log the change after the delete succeeds. Best-effort: a Warn-level
|
||||
// failure here would leave the cache index orphaned on offline clients
|
||||
// until the next scan touches the surrounding album.
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
tq := dbq.New(tx)
|
||||
|
||||
deleted, err := tq.DeleteTrack(ctx, trackID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// Removed by someone else between the lookup and here.
|
||||
return DeletedTrack{}, ErrTrackNotFound
|
||||
}
|
||||
return DeletedTrack{}, fmt.Errorf("delete track: %w", err)
|
||||
}
|
||||
|
||||
var out DeletedTrack
|
||||
album, err := tq.DeleteAlbumIfEmpty(ctx, deleted.AlbumID)
|
||||
switch {
|
||||
case err == nil:
|
||||
albumID := album.ID
|
||||
out.AlbumID = &albumID
|
||||
artistID, aerr := tq.DeleteArtistIfEmpty(ctx, album.ArtistID)
|
||||
switch {
|
||||
case aerr == nil:
|
||||
out.ArtistID = &artistID
|
||||
case errors.Is(aerr, pgx.ErrNoRows):
|
||||
// The artist still has other albums or stray tracks.
|
||||
default:
|
||||
return DeletedTrack{}, fmt.Errorf("delete artist if empty: %w", aerr)
|
||||
}
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
// The album still has other tracks.
|
||||
default:
|
||||
return DeletedTrack{}, fmt.Errorf("delete album if empty: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return DeletedTrack{}, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
|
||||
// Both of these run after the delete has committed, so neither may fail
|
||||
// it: the recording is gone either way. An unlogged change leaves the track
|
||||
// in offline clients' caches until the next scan touches its album; a
|
||||
// leftover art directory is only disk.
|
||||
if err := syncpkg.LogChange(ctx, pool, syncpkg.EntityTrack,
|
||||
syncpkg.FormatUUID(trackID), syncpkg.OpDelete); err != nil {
|
||||
return fmt.Errorf("log change: %w", err)
|
||||
logger.Warn("track delete: LogChange failed", "track_id", syncpkg.FormatUUID(trackID), "err", err)
|
||||
}
|
||||
return nil
|
||||
if out.ArtistID != nil && dataDir != "" {
|
||||
if err := coverart.CleanupArtistArt(dataDir, *out.ArtistID); err != nil {
|
||||
logger.Warn("track delete: artist-art cleanup failed",
|
||||
"artist_id", syncpkg.FormatUUID(*out.ArtistID), "err", err)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
@@ -64,6 +66,15 @@ func seedTrack(t *testing.T, pool *pgxpool.Pool, filePath string) (dbq.Track, db
|
||||
return track, album, artist
|
||||
}
|
||||
|
||||
// stubRemoveFile makes file removal fail (or succeed) on demand for one test.
|
||||
// See removeFile for why this is a seam rather than a chmod.
|
||||
func stubRemoveFile(t *testing.T, fn func(string) error) {
|
||||
t.Helper()
|
||||
orig := removeFile
|
||||
removeFile = fn
|
||||
t.Cleanup(func() { removeFile = orig })
|
||||
}
|
||||
|
||||
func TestDeleteTrackFile_HappyPath(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
q := dbq.New(pool)
|
||||
@@ -73,9 +84,18 @@ func TestDeleteTrackFile_HappyPath(t *testing.T) {
|
||||
if err := os.WriteFile(path, []byte("payload"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
track, album, _ := seedTrack(t, pool, path)
|
||||
track, album, artist := seedTrack(t, pool, path)
|
||||
// A sibling keeps the album non-empty, so this case pins that the tidy-up
|
||||
// only removes an album the delete actually emptied.
|
||||
if _, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||||
Title: "Sibling", AlbumID: album.ID, ArtistID: artist.ID,
|
||||
DurationMs: 1000, FilePath: filepath.Join(dir, "sibling.mp3"), FileSize: 100, FileFormat: "mp3",
|
||||
}); err != nil {
|
||||
t.Fatalf("sibling: %v", err)
|
||||
}
|
||||
|
||||
if err := DeleteTrackFile(context.Background(), pool, track.ID); err != nil {
|
||||
got, err := DeleteTrackFile(context.Background(), pool, nil, "", track.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteTrackFile: %v", err)
|
||||
}
|
||||
|
||||
@@ -85,9 +105,99 @@ func TestDeleteTrackFile_HappyPath(t *testing.T) {
|
||||
if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil {
|
||||
t.Errorf("track row still exists")
|
||||
}
|
||||
// Album row preserved (other tracks may reference it).
|
||||
if _, err := q.GetAlbumByID(context.Background(), album.ID); err != nil {
|
||||
t.Errorf("album row vanished: %v", err)
|
||||
t.Errorf("album with a remaining track vanished: %v", err)
|
||||
}
|
||||
if got.AlbumID != nil || got.ArtistID != nil {
|
||||
t.Errorf("reported tidy-up %+v for an album that still has a track", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteTrackFile_EmptiedAlbumAndArtistGoToo(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
q := dbq.New(pool)
|
||||
|
||||
path := filepath.Join(t.TempDir(), "lone.mp3")
|
||||
if err := os.WriteFile(path, []byte("payload"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
track, album, artist := seedTrack(t, pool, path)
|
||||
|
||||
got, err := DeleteTrackFile(context.Background(), pool, nil, "", track.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteTrackFile: %v", err)
|
||||
}
|
||||
if got.AlbumID == nil || *got.AlbumID != album.ID {
|
||||
t.Errorf("AlbumID = %v, want %v", got.AlbumID, album.ID)
|
||||
}
|
||||
if got.ArtistID == nil || *got.ArtistID != artist.ID {
|
||||
t.Errorf("ArtistID = %v, want %v", got.ArtistID, artist.ID)
|
||||
}
|
||||
if _, err := q.GetAlbumByID(context.Background(), album.ID); err == nil {
|
||||
t.Errorf("emptied album row still exists")
|
||||
}
|
||||
if _, err := q.GetArtistByID(context.Background(), artist.ID); err == nil {
|
||||
t.Errorf("emptied artist row still exists")
|
||||
}
|
||||
}
|
||||
|
||||
// The #3918 proof. A file that cannot be removed must leave EVERYTHING in place:
|
||||
// the row is what carries likes, plays and playlist memberships, and the file
|
||||
// surviving means the next scan would re-import it as a stranger.
|
||||
func TestDeleteTrackFile_UnremovableFileDeletesNothing(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
errno syscall.Errno
|
||||
notWritable bool
|
||||
}{
|
||||
{"read-only mount", syscall.EROFS, true},
|
||||
{"permission denied", syscall.EACCES, true},
|
||||
{"operation not permitted", syscall.EPERM, true},
|
||||
{"i/o error", syscall.EIO, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
q := dbq.New(pool)
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "track.mp3")
|
||||
if err := os.WriteFile(path, []byte("payload"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
track, album, _ := seedTrack(t, pool, path)
|
||||
stubRemoveFile(t, func(name string) error {
|
||||
return &fs.PathError{Op: "remove", Path: name, Err: tc.errno}
|
||||
})
|
||||
|
||||
_, err := DeleteTrackFile(context.Background(), pool, nil, "", track.ID)
|
||||
|
||||
var fre *FileRemoveError
|
||||
if !errors.As(err, &fre) {
|
||||
t.Fatalf("err = %v, want a *FileRemoveError", err)
|
||||
}
|
||||
if fre.NotWritable() != tc.notWritable {
|
||||
t.Errorf("NotWritable = %v, want %v", fre.NotWritable(), tc.notWritable)
|
||||
}
|
||||
if fre.Dir() != dir {
|
||||
t.Errorf("Dir = %q, want the parent directory %q", fre.Dir(), dir)
|
||||
}
|
||||
if fre.Reason() != tc.errno.Error() {
|
||||
t.Errorf("Reason = %q, want %q", fre.Reason(), tc.errno.Error())
|
||||
}
|
||||
if fre.UID != os.Getuid() || fre.GID != os.Getgid() {
|
||||
t.Errorf("identity = %d:%d, want this process's %d:%d", fre.UID, fre.GID, os.Getuid(), os.Getgid())
|
||||
}
|
||||
if _, err := q.GetTrackByID(context.Background(), track.ID); err != nil {
|
||||
t.Errorf("track row was deleted although its file was not: %v", err)
|
||||
}
|
||||
if _, err := q.GetAlbumByID(context.Background(), album.ID); err != nil {
|
||||
t.Errorf("album row was deleted although the track's file was not: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Errorf("file gone although removal was refused: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +207,7 @@ func TestDeleteTrackFile_FileAlreadyGoneSucceeds(t *testing.T) {
|
||||
|
||||
track, _, _ := seedTrack(t, pool, "/no/such/file/anywhere.mp3")
|
||||
|
||||
if err := DeleteTrackFile(context.Background(), pool, track.ID); err != nil {
|
||||
if _, err := DeleteTrackFile(context.Background(), pool, nil, "", track.ID); err != nil {
|
||||
t.Fatalf("DeleteTrackFile with missing file: %v", err)
|
||||
}
|
||||
if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil {
|
||||
@@ -112,7 +222,7 @@ func TestDeleteTrackFile_NotFoundReturnsErr(t *testing.T) {
|
||||
bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
|
||||
bogus.Valid = true
|
||||
|
||||
err := DeleteTrackFile(context.Background(), pool, bogus)
|
||||
_, err := DeleteTrackFile(context.Background(), pool, nil, "", bogus)
|
||||
if !errors.Is(err, ErrTrackNotFound) {
|
||||
t.Errorf("err = %v, want ErrTrackNotFound", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// Acoustic identity (M400).
|
||||
//
|
||||
// Two values per track, because they answer different questions:
|
||||
//
|
||||
// audio_stream_sha256 a SHA-256 of the ENCODED audio packets. Equal means the
|
||||
// same audio bytes, whatever the tags or container around
|
||||
// them say. No threshold and no false positives — this is
|
||||
// what catches two copies of one MP3 that differ only in
|
||||
// their ID3 (#3885).
|
||||
//
|
||||
// chromaprint fpcalc's raw fingerprint. Close means the same
|
||||
// recording, even at another bitrate or in another codec
|
||||
// — the case an exact hash cannot see.
|
||||
//
|
||||
// Both shell out, in the shape probeDurationMs already set: a deadline on every
|
||||
// call, and a failure that leaves the value unset rather than failing the file.
|
||||
// A track with no fingerprint is never a duplicate candidate; it is still a
|
||||
// track.
|
||||
|
||||
// fingerprintTimeout bounds one ffmpeg hash or fpcalc call. Longer than
|
||||
// probeTimeout because both read the audio rather than a header: the hash reads
|
||||
// every packet and fpcalc decodes up to its -length. 60s leaves room for a large
|
||||
// lossless file on a slow network mount; a call needing more is a stall, not a
|
||||
// big file.
|
||||
const fingerprintTimeout = 60 * time.Second
|
||||
|
||||
// fingerprintWaitDelay bounds how long Output may keep waiting on the tool's
|
||||
// pipes after the deadline has killed it. Without it, a child that left a
|
||||
// descendant holding stdout open would block the scan past its own timeout.
|
||||
const fingerprintWaitDelay = 5 * time.Second
|
||||
|
||||
// fingerprintVersion stamps how a track_fingerprints row was derived. Bump it
|
||||
// whenever the derivation changes — the hash arguments, fpcalc's flags or its
|
||||
// length — and the backfill re-derives every row below it. Fingerprints taken
|
||||
// by two methods are not comparable, and nothing else would reveal that the
|
||||
// library held a mix.
|
||||
const fingerprintVersion int16 = 1
|
||||
|
||||
// errFingerprintTimeout marks a tool that ran out of time. Distinct from a
|
||||
// failed exit because a stall is a fact about the mount, not about the file.
|
||||
var errFingerprintTimeout = errors.New("fingerprint tool timed out")
|
||||
|
||||
// defaultChromaprintLengthSec is how many seconds of audio fpcalc fingerprints.
|
||||
// 120 is fpcalc's own default. Fingerprints taken at different lengths are not
|
||||
// comparable, so changing this has to re-derive every stored one.
|
||||
const defaultChromaprintLengthSec = 120
|
||||
|
||||
// fpcalcStderrTail caps how much of a failing tool's stderr reaches the log.
|
||||
const fpcalcStderrTail = 512
|
||||
|
||||
// streamHashArgs hashes the encoded audio packets, never decoded samples.
|
||||
//
|
||||
// -c:a copy is the point, not an optimisation. A decoded hash of a lossy file
|
||||
// depends on the decoder's float maths and sample conversion, which can move
|
||||
// between ffmpeg releases — so an image upgrade could silently change every
|
||||
// stored hash, and yesterday's duplicate would stop matching today's copy.
|
||||
// Packet bytes do not move. It is also far cheaper: demux only, no decode.
|
||||
//
|
||||
// -map 0:a keeps embedded cover art (an attached-picture video stream) out of
|
||||
// the hash, so two copies of one recording carrying different art still match.
|
||||
func streamHashArgs(path string) []string {
|
||||
return []string{
|
||||
"-v", "error",
|
||||
"-i", path,
|
||||
"-map", "0:a",
|
||||
"-c:a", "copy",
|
||||
"-f", "hash", "-hash", "sha256",
|
||||
"-",
|
||||
}
|
||||
}
|
||||
|
||||
// fpcalcArgs asks for the raw fingerprint as SIGNED integers.
|
||||
//
|
||||
// -raw because the matcher compares items bit by bit, which the compressed form
|
||||
// cannot do without being unpacked first. -signed because the column is Postgres
|
||||
// integer[], which is signed: fpcalc's default prints uint32, and half of those
|
||||
// values do not fit. Signed output is the same 32 bits with no reinterpretation
|
||||
// step left to get wrong.
|
||||
func fpcalcArgs(path string, lengthSec int) []string {
|
||||
return []string{
|
||||
"-raw", "-signed",
|
||||
"-length", strconv.Itoa(lengthSec),
|
||||
path,
|
||||
}
|
||||
}
|
||||
|
||||
// fingerprintResult is one attempt at both halves of a track's identity. They
|
||||
// fail independently: a file ffmpeg can demux may still defeat fpcalc.
|
||||
type fingerprintResult struct {
|
||||
streamSHA256 []byte
|
||||
chromaprint []int32
|
||||
hashErr error
|
||||
printErr error
|
||||
}
|
||||
|
||||
// computeFingerprint derives both halves for the file at path.
|
||||
func computeFingerprint(ctx context.Context, path string) fingerprintResult {
|
||||
var r fingerprintResult
|
||||
r.streamSHA256, r.hashErr = computeAudioStreamSHA256(ctx, path)
|
||||
r.chromaprint, r.printErr = computeChromaprint(ctx, path, defaultChromaprintLengthSec)
|
||||
return r
|
||||
}
|
||||
|
||||
// inconclusive reports whether either half failed for a reason that says
|
||||
// nothing about the file. Such a result must never be stored: stamped at the
|
||||
// current version it would read as "tried, and this file cannot be
|
||||
// fingerprinted", and the backfill would never try it again.
|
||||
func (r fingerprintResult) inconclusive() bool {
|
||||
return isInconclusive(r.hashErr) || isInconclusive(r.printErr)
|
||||
}
|
||||
|
||||
// isInconclusive names the failures that are not a verdict on the file: a
|
||||
// stall, a cancelled scan, and a tool that is not installed. The last matters
|
||||
// outside the image — a dev binary run without fpcalc on PATH must not stamp
|
||||
// every track in the library as unfingerprintable.
|
||||
func isInconclusive(err error) bool {
|
||||
return errors.Is(err, errFingerprintTimeout) ||
|
||||
errors.Is(err, context.Canceled) ||
|
||||
errors.Is(err, context.DeadlineExceeded) ||
|
||||
errors.Is(err, exec.ErrNotFound)
|
||||
}
|
||||
|
||||
// fingerprintFile runs the scanner's fingerprinter. A Scanner built without New
|
||||
// gets the real tools rather than a nil-func panic halfway through a scan.
|
||||
func (s *Scanner) fingerprintFile(ctx context.Context, path string) fingerprintResult {
|
||||
if s.fingerprint == nil {
|
||||
return computeFingerprint(ctx, path)
|
||||
}
|
||||
return s.fingerprint(ctx, path)
|
||||
}
|
||||
|
||||
// storeFingerprint records one attempt for a track whose bytes are new or have
|
||||
// changed. It never fails the scan: a missing fingerprint only keeps a track
|
||||
// out of duplicate detection, which is not worth dropping the track over.
|
||||
func (s *Scanner) storeFingerprint(
|
||||
ctx context.Context, q *dbq.Queries, trackID pgtype.UUID, path string, fp fingerprintResult,
|
||||
) {
|
||||
if fp.hashErr != nil {
|
||||
s.logger.Warn("library scan: audio stream hash failed", "path", path, "err", fp.hashErr)
|
||||
}
|
||||
if fp.printErr != nil {
|
||||
s.logger.Warn("library scan: chromaprint failed", "path", path, "err", fp.printErr)
|
||||
}
|
||||
if fp.inconclusive() {
|
||||
// Any row this track holds describes its PREVIOUS bytes. Drop it and
|
||||
// leave the track to the backfill, rather than stamping a failure that
|
||||
// says nothing about this file.
|
||||
if err := q.DeleteTrackFingerprint(ctx, trackID); err != nil {
|
||||
s.logger.Warn("library scan: clearing stale fingerprint failed", "path", path, "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
// A NULL half here is a verdict — the tool ran and rejected this file — and
|
||||
// is stamped at the current version so the backfill does not retry it on
|
||||
// every boot. It is retried when the file changes.
|
||||
if err := q.UpsertTrackFingerprint(ctx, dbq.UpsertTrackFingerprintParams{
|
||||
TrackID: trackID,
|
||||
AudioStreamSha256: fp.streamSHA256,
|
||||
Chromaprint: fp.chromaprint,
|
||||
FingerprintVersion: fingerprintVersion,
|
||||
}); err != nil {
|
||||
s.logger.Warn("library scan: storing fingerprint failed", "path", path, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// computeAudioStreamSHA256 returns the SHA-256 of the file's encoded audio.
|
||||
func computeAudioStreamSHA256(ctx context.Context, path string) ([]byte, error) {
|
||||
out, err := runFingerprintTool(ctx, "ffmpeg", streamHashArgs(path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseStreamHash(out)
|
||||
}
|
||||
|
||||
// computeChromaprint returns the raw acoustic fingerprint of the first
|
||||
// lengthSec seconds of the file.
|
||||
func computeChromaprint(ctx context.Context, path string, lengthSec int) ([]int32, error) {
|
||||
out, err := runFingerprintTool(ctx, "fpcalc", fpcalcArgs(path, lengthSec))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseFpcalcRaw(out)
|
||||
}
|
||||
|
||||
// runFingerprintTool runs one tool under fingerprintTimeout.
|
||||
//
|
||||
// Any non-zero exit is an error, and that deliberately includes fpcalc's exit 3:
|
||||
// "reading failed, but here is a fingerprint of what I got". A partial
|
||||
// fingerprint of a damaged file is not that file's identity. Stored, it would
|
||||
// score against a healthy copy over whatever prefix survived, and could group
|
||||
// or fail to group either way. Absent is better than wrong.
|
||||
func runFingerprintTool(ctx context.Context, name string, args []string) ([]byte, error) {
|
||||
runCtx, cancel := context.WithTimeout(ctx, fingerprintTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(runCtx, name, args...)
|
||||
cmd.WaitDelay = fingerprintWaitDelay
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
return out, nil
|
||||
}
|
||||
// The caller gave up (a cancelled scan). Report that rather than the
|
||||
// signal-killed exit it caused, so it is never mistaken for a verdict on
|
||||
// the file.
|
||||
if ctx.Err() != nil {
|
||||
return nil, fmt.Errorf("%s: %w", name, ctx.Err())
|
||||
}
|
||||
// Named separately so a stall reads as a stall, not as a crash.
|
||||
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
|
||||
return nil, fmt.Errorf("%s: no result within %s: %w", name, fingerprintTimeout, errFingerprintTimeout)
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return nil, fmt.Errorf("%s exited %d: %s", name, exitErr.ExitCode(), stderrTail(exitErr.Stderr))
|
||||
}
|
||||
return nil, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
|
||||
// stderrTail keeps the END of a failing tool's stderr. ffmpeg and fpcalc print
|
||||
// the actual reason last, after any banner or per-frame warnings, so a cap that
|
||||
// kept the head would log the noise and drop the cause.
|
||||
func stderrTail(stderr []byte) []byte {
|
||||
stderr = bytes.TrimSpace(stderr)
|
||||
if len(stderr) > fpcalcStderrTail {
|
||||
stderr = stderr[len(stderr)-fpcalcStderrTail:]
|
||||
}
|
||||
return stderr
|
||||
}
|
||||
|
||||
// parseStreamHash reads the ffmpeg hash muxer's "SHA256=<hex>" line.
|
||||
func parseStreamHash(out []byte) ([]byte, error) {
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
hexed, ok := strings.CutPrefix(strings.TrimSpace(line), "SHA256=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sum, err := hex.DecodeString(hexed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stream hash %q: %w", hexed, err)
|
||||
}
|
||||
if len(sum) != sha256.Size {
|
||||
return nil, fmt.Errorf("stream hash is %d bytes, want %d", len(sum), sha256.Size)
|
||||
}
|
||||
return sum, nil
|
||||
}
|
||||
return nil, errors.New("ffmpeg printed no SHA256= line")
|
||||
}
|
||||
|
||||
// parseFpcalcRaw reads fpcalc's text output:
|
||||
//
|
||||
// DURATION=<seconds>
|
||||
// FINGERPRINT=<int32>,<int32>,...
|
||||
func parseFpcalcRaw(out []byte) ([]int32, error) {
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
list, ok := strings.CutPrefix(strings.TrimSpace(line), "FINGERPRINT=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if list == "" {
|
||||
return nil, errors.New("fpcalc returned an empty fingerprint")
|
||||
}
|
||||
items := strings.Split(list, ",")
|
||||
fp := make([]int32, len(items))
|
||||
for i, item := range items {
|
||||
// ParseInt at 32 bits, not ParseUint: a value past int32 means the
|
||||
// output was unsigned — -signed went missing from the invocation —
|
||||
// and nothing downstream would reinterpret it. Refuse it here.
|
||||
v, err := strconv.ParseInt(item, 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fingerprint item %d %q: %w", i, item, err)
|
||||
}
|
||||
fp[i] = int32(v)
|
||||
}
|
||||
return fp, nil
|
||||
}
|
||||
return nil, errors.New("fpcalc printed no FINGERPRINT= line")
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
)
|
||||
|
||||
// TestScanner_FingerprintsOnlyNewOrChangedBytes_Integration pins WHEN the scan
|
||||
// fingerprints. The cost of getting it wrong is asymmetric and invisible: a
|
||||
// scan that re-fingerprints unchanged files still produces correct rows, just
|
||||
// by decoding the entire library on every tag-repair pass.
|
||||
//
|
||||
// The fingerprinter is stubbed. CI has no real audio, and the tools' output is
|
||||
// covered by the parser tests; this covers the scan's decisions.
|
||||
func TestScanner_FingerprintsOnlyNewOrChangedBytes_Integration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping scanner integration in -short mode")
|
||||
}
|
||||
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
if err := db.Migrate(dsn, logger); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
if _, err := pool.Exec(ctx, "TRUNCATE tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
a := filepath.Join(root, "artist/album/01.mp3")
|
||||
b := filepath.Join(root, "artist/album/02.mp3")
|
||||
writeTestMP3(t, a, map[string]string{"TIT2": "One", "TPE1": "Artist", "TALB": "Album", "TRCK": "1"})
|
||||
writeTestMP3(t, b, map[string]string{"TIT2": "Two", "TPE1": "Artist", "TALB": "Album", "TRCK": "2"})
|
||||
|
||||
sum := bytes.Repeat([]byte{0xAB}, 32)
|
||||
chroma := []int32{7, -7, 2147483647}
|
||||
result := fingerprintResult{streamSHA256: sum, chromaprint: chroma}
|
||||
calls := map[string]int{}
|
||||
|
||||
scanner := New(pool, logger, []string{root})
|
||||
scanner.fingerprint = func(_ context.Context, path string) fingerprintResult {
|
||||
calls[path]++
|
||||
return result
|
||||
}
|
||||
|
||||
scan := func(step string) Stats {
|
||||
t.Helper()
|
||||
st, err := scanner.Scan(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: scan: %v", step, err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
type row struct {
|
||||
sha []byte
|
||||
chroma []int32
|
||||
version int16
|
||||
}
|
||||
stored := func(path string) (row, bool) {
|
||||
t.Helper()
|
||||
var r row
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT f.audio_stream_sha256, f.chromaprint, f.fingerprint_version
|
||||
FROM track_fingerprints f JOIN tracks t ON t.id = f.track_id
|
||||
WHERE t.file_path = $1`, path).Scan(&r.sha, &r.chroma, &r.version)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return row{}, false
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read fingerprint for %s: %v", path, err)
|
||||
}
|
||||
return r, true
|
||||
}
|
||||
// A later step moves mtime forward past the row's updated_at, which is
|
||||
// what the scan reads as "these bytes changed".
|
||||
touch := func(path string, ahead time.Duration) {
|
||||
t.Helper()
|
||||
when := time.Now().Add(ahead)
|
||||
if err := os.Chtimes(path, when, when); err != nil {
|
||||
t.Fatalf("chtimes %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. New files are fingerprinted, and stored at the current version.
|
||||
scan("first scan")
|
||||
if calls[a] != 1 || calls[b] != 1 {
|
||||
t.Fatalf("first scan fingerprint calls = %v, want one per file", calls)
|
||||
}
|
||||
got, ok := stored(a)
|
||||
if !ok {
|
||||
t.Fatal("first scan stored no fingerprint")
|
||||
}
|
||||
if !bytes.Equal(got.sha, sum) || !slices.Equal(got.chroma, chroma) || got.version != fingerprintVersion {
|
||||
t.Fatalf("stored %+v, want sha %x chromaprint %v version %d", got, sum, chroma, fingerprintVersion)
|
||||
}
|
||||
|
||||
// 2. A tag-repair pass re-reads every unchanged file and must not
|
||||
// fingerprint any of them again.
|
||||
//
|
||||
// The Updated count is what makes this able to fail. Without it, a scan
|
||||
// that simply SKIPPED both files would also leave the call counts at one,
|
||||
// and the assertion would pass without the re-read path ever running.
|
||||
if _, err := pool.Exec(ctx, "UPDATE tracks SET duration_ms = 1000, tag_read_version = 0"); err != nil {
|
||||
t.Fatalf("force tag re-read: %v", err)
|
||||
}
|
||||
if st := scan("tag-repair scan"); st.Updated != 2 || st.Skipped != 0 {
|
||||
t.Fatalf("tag-repair scan stats = %+v, want both files re-read (Updated=2 Skipped=0)", st)
|
||||
}
|
||||
if calls[a] != 1 || calls[b] != 1 {
|
||||
t.Fatalf("tag-repair scan re-fingerprinted unchanged files: calls = %v", calls)
|
||||
}
|
||||
if _, ok := stored(a); !ok {
|
||||
t.Fatal("tag-repair scan dropped a stored fingerprint")
|
||||
}
|
||||
|
||||
// 3. Bytes that changed are fingerprinted again, and only those.
|
||||
touch(a, time.Hour)
|
||||
scan("changed-file scan")
|
||||
if calls[a] != 2 || calls[b] != 1 {
|
||||
t.Fatalf("changed-file scan calls = %v, want a=2 b=1", calls)
|
||||
}
|
||||
|
||||
// 4. A changed file whose attempt is inconclusive loses its old row: that
|
||||
// row describes the previous bytes, and a stall says nothing about the new
|
||||
// ones.
|
||||
result = fingerprintResult{streamSHA256: sum, printErr: fmt.Errorf("fpcalc: %w", errFingerprintTimeout)}
|
||||
touch(a, 2*time.Hour)
|
||||
scan("inconclusive scan")
|
||||
if _, ok := stored(a); ok {
|
||||
t.Fatal("inconclusive attempt left the previous bytes' fingerprint in place")
|
||||
}
|
||||
if _, ok := stored(b); !ok {
|
||||
t.Fatal("inconclusive attempt on one file removed another file's fingerprint")
|
||||
}
|
||||
|
||||
// 5. A file the tools reject gets a row at the current version with both
|
||||
// halves NULL — a verdict, so the backfill does not retry it every boot.
|
||||
result = fingerprintResult{
|
||||
hashErr: errors.New("ffmpeg exited 1"),
|
||||
printErr: errors.New("fpcalc exited 2"),
|
||||
}
|
||||
touch(a, 3*time.Hour)
|
||||
scan("rejected scan")
|
||||
got, ok = stored(a)
|
||||
if !ok {
|
||||
t.Fatal("a file the tools rejected got no row, so the backfill would retry it forever")
|
||||
}
|
||||
if got.sha != nil || got.chroma != nil || got.version != fingerprintVersion {
|
||||
t.Fatalf("rejected file stored %+v, want both halves NULL at version %d", got, fingerprintVersion)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseFpcalcRaw(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
out string
|
||||
want []int32
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "signed output with negatives",
|
||||
out: "DURATION=213\nFINGERPRINT=-1453821711,17,0,2147483647,-2147483648\n",
|
||||
want: []int32{-1453821711, 17, 0, 2147483647, -2147483648},
|
||||
},
|
||||
{
|
||||
name: "fingerprint line need not come second",
|
||||
out: "FINGERPRINT=5,6\nDURATION=1\n",
|
||||
want: []int32{5, 6},
|
||||
},
|
||||
{
|
||||
// fpcalc's default is uint32. This value only appears when -signed
|
||||
// is missing, and storing it would need a reinterpretation nothing
|
||||
// performs.
|
||||
name: "unsigned output is refused",
|
||||
out: "DURATION=213\nFINGERPRINT=2841145585,17\n",
|
||||
wantErr: "item 0",
|
||||
},
|
||||
{name: "empty fingerprint", out: "DURATION=0\nFINGERPRINT=\n", wantErr: "empty fingerprint"},
|
||||
{name: "no fingerprint line", out: "DURATION=213\n", wantErr: "no FINGERPRINT= line"},
|
||||
{name: "non-numeric item", out: "FINGERPRINT=1,x,3\n", wantErr: "item 1"},
|
||||
{name: "trailing comma", out: "FINGERPRINT=1,2,\n", wantErr: "item 2"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseFpcalcRaw([]byte(tc.out))
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("err = %v, want one containing %q", err, tc.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if !slices.Equal(got, tc.want) {
|
||||
t.Fatalf("got %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStreamHash(t *testing.T) {
|
||||
// The real value ffmpeg printed for both files of the #3885 pair.
|
||||
const www = "24e2daa3b4a534ff1a8d1a76f67810205869daf89f728d83a16625da4d28a18e"
|
||||
|
||||
got, err := parseStreamHash([]byte("SHA256=" + www + "\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if len(got) != 32 || got[0] != 0x24 || got[31] != 0x8e {
|
||||
t.Fatalf("decoded %x, want %s", got, www)
|
||||
}
|
||||
|
||||
for name, out := range map[string]string{
|
||||
"no hash line": "",
|
||||
"other hash": "MD5=" + www[:32] + "\n",
|
||||
"not hex": "SHA256=" + strings.Repeat("zz", 32) + "\n",
|
||||
"short digest": "SHA256=" + www[:62] + "\n",
|
||||
"odd hex chars": "SHA256=" + www[:63] + "\n",
|
||||
} {
|
||||
if _, err := parseStreamHash([]byte(out)); err == nil {
|
||||
t.Errorf("%s: parsed %q without error", name, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// followedBy reports whether flag appears in args immediately followed by value.
|
||||
func followedBy(args []string, flag, value string) bool {
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == flag && args[i+1] == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// The exact tier's stored hashes must stay comparable across ffmpeg upgrades,
|
||||
// which only holds while the packets are copied rather than decoded. A decoded
|
||||
// hash still matches within one ffmpeg build, so nothing else would notice the
|
||||
// change until an image upgrade silently broke every stored value.
|
||||
func TestStreamHashArgs_HashPacketsNotSamples(t *testing.T) {
|
||||
args := streamHashArgs("/music/a.mp3")
|
||||
for _, pair := range [][2]string{
|
||||
{"-c:a", "copy"}, // no decode
|
||||
{"-map", "0:a"}, // audio only: cover art stays out of the hash
|
||||
{"-f", "hash"}, // the hash muxer, not a file
|
||||
{"-hash", "sha256"},
|
||||
{"-i", "/music/a.mp3"},
|
||||
} {
|
||||
if !followedBy(args, pair[0], pair[1]) {
|
||||
t.Errorf("streamHashArgs lacks %s %s: %v", pair[0], pair[1], args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFpcalcArgs_RequestSignedRawOutput(t *testing.T) {
|
||||
args := fpcalcArgs("/music/a.flac", 90)
|
||||
for _, flag := range []string{"-raw", "-signed"} {
|
||||
if !slices.Contains(args, flag) {
|
||||
t.Errorf("fpcalcArgs lacks %s: %v", flag, args)
|
||||
}
|
||||
}
|
||||
if !followedBy(args, "-length", "90") {
|
||||
t.Errorf("fpcalcArgs does not pass the requested length: %v", args)
|
||||
}
|
||||
// fpcalc takes the file as its trailing positional argument.
|
||||
if args[len(args)-1] != "/music/a.flac" {
|
||||
t.Errorf("path is not last: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStderrTail_KeepsTheCauseNotTheBanner(t *testing.T) {
|
||||
banner := bytes.Repeat([]byte("warning: skipping frame\n"), fpcalcStderrTail)
|
||||
got := stderrTail(append(banner, []byte("ERROR: could not decode\n")...))
|
||||
if len(got) != fpcalcStderrTail {
|
||||
t.Fatalf("tail is %d bytes, want the %d-byte cap", len(got), fpcalcStderrTail)
|
||||
}
|
||||
if !bytes.HasSuffix(got, []byte("ERROR: could not decode")) {
|
||||
t.Fatalf("tail dropped the final line: ...%q", got[len(got)-40:])
|
||||
}
|
||||
|
||||
if got := stderrTail([]byte(" short \n")); string(got) != "short" {
|
||||
t.Fatalf("short stderr = %q, want it trimmed and whole", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A stored failure is permanent until the file changes, so the classification
|
||||
// decides whether a track is ever retried. Every inconclusive case here would,
|
||||
// if misfiled as a verdict, silently exclude that track from duplicate
|
||||
// detection for good.
|
||||
func TestIsInconclusive(t *testing.T) {
|
||||
notInstalled := fmt.Errorf("fpcalc: %w", &exec.Error{Name: "fpcalc", Err: exec.ErrNotFound})
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"timeout", fmt.Errorf("fpcalc: no result: %w", errFingerprintTimeout), true},
|
||||
{"scan cancelled", fmt.Errorf("ffmpeg: %w", context.Canceled), true},
|
||||
{"caller deadline", fmt.Errorf("ffmpeg: %w", context.DeadlineExceeded), true},
|
||||
{"tool not installed", notInstalled, true},
|
||||
{"tool rejected the file", errors.New("fpcalc exited 2: could not decode"), false},
|
||||
{"unparseable output", errors.New("fpcalc printed no FINGERPRINT= line"), false},
|
||||
{"success", nil, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := isInconclusive(tc.err); got != tc.want {
|
||||
t.Errorf("%s: isInconclusive = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Either half being inconclusive taints the whole result: storing the half that
|
||||
// succeeded would stamp the row at the current version with the other half
|
||||
// NULL, and that NULL would then read as a verdict.
|
||||
func TestFingerprintResult_InconclusiveIfEitherHalfIs(t *testing.T) {
|
||||
stall := fmt.Errorf("fpcalc: %w", errFingerprintTimeout)
|
||||
rejected := errors.New("fpcalc exited 2")
|
||||
for name, tc := range map[string]struct {
|
||||
r fingerprintResult
|
||||
want bool
|
||||
}{
|
||||
"both succeeded": {fingerprintResult{streamSHA256: []byte{1}, chromaprint: []int32{1}}, false},
|
||||
"hash ok, print stalled": {fingerprintResult{streamSHA256: []byte{1}, printErr: stall}, true},
|
||||
"hash stalled, print ok": {fingerprintResult{hashErr: stall, chromaprint: []int32{1}}, true},
|
||||
"hash ok, print rejected": {fingerprintResult{streamSHA256: []byte{1}, printErr: rejected}, false},
|
||||
"both rejected by the file": {fingerprintResult{hashErr: rejected, printErr: rejected}, false},
|
||||
} {
|
||||
if got := tc.r.inconclusive(); got != tc.want {
|
||||
t.Errorf("%s: inconclusive = %v, want %v", name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,10 +75,15 @@ type Scanner struct {
|
||||
pool *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
paths []string
|
||||
// fingerprint derives a file's acoustic identity (M400). A field so an
|
||||
// integration test can substitute a deterministic one: CI has no real audio
|
||||
// to fingerprint, and what the test pins is WHEN the scan fingerprints, not
|
||||
// what the tools print. Call it through fingerprintFile.
|
||||
fingerprint func(ctx context.Context, path string) fingerprintResult
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool, logger *slog.Logger, paths []string) *Scanner {
|
||||
return &Scanner{pool: pool, logger: logger, paths: paths}
|
||||
return &Scanner{pool: pool, logger: logger, paths: paths, fingerprint: computeFingerprint}
|
||||
}
|
||||
|
||||
// Scan walks every configured root and upserts any audio file whose mtime is
|
||||
@@ -295,6 +300,25 @@ func (s *Scanner) scanFile(
|
||||
durationMs = probed
|
||||
}
|
||||
|
||||
// Fingerprint only bytes this row has not seen: a new path, or a file whose
|
||||
// mtime moved past the row's. An unchanged file re-read for a tag repair
|
||||
// keeps its stored fingerprint, for the same reason it keeps its duration
|
||||
// above — a tagReadVersion bump must stay bound by tag reads, not become a
|
||||
// decode of the whole library.
|
||||
//
|
||||
// An unchanged file with NO fingerprint yet is the backfill's job (#3908),
|
||||
// deliberately not the scan's. Folding it into the skip check would make
|
||||
// the first scan after an upgrade re-decode every track and push a sync
|
||||
// change to every client for each one.
|
||||
//
|
||||
// Computed before move adoption so adoption can match on the audio hash
|
||||
// (#3914); stored after the upsert, once the row id is known.
|
||||
var fp fingerprintResult
|
||||
fingerprinted := !unchanged
|
||||
if fingerprinted {
|
||||
fp = s.fingerprintFile(ctx, path)
|
||||
}
|
||||
|
||||
// A path we've never seen might not be a new track — it might be one that
|
||||
// moved or was renamed (#2528). Adopting re-points the existing row at this
|
||||
// path and clears its missing mark, so the UpsertTrack below conflicts on
|
||||
@@ -359,6 +383,9 @@ func (s *Scanner) scanFile(
|
||||
// touches this track will re-emit the change.
|
||||
s.logger.Warn("library scan: LogChange track upsert failed", "track_id", track.ID, "err", err)
|
||||
}
|
||||
if fingerprinted {
|
||||
s.storeFingerprint(ctx, q, track.ID, path, fp)
|
||||
}
|
||||
|
||||
if knownTrack {
|
||||
stats.Updated++
|
||||
|
||||
@@ -35,6 +35,9 @@ var (
|
||||
// config changes in lidarrconfig take effect immediately. clientFn returns
|
||||
// nil when Lidarr is disabled.
|
||||
type Service struct {
|
||||
// dataDir lets a Delete file that empties an artist clear that artist's
|
||||
// cached art, the same as the admin remove-track path.
|
||||
dataDir string
|
||||
pool *pgxpool.Pool
|
||||
lidarrCfg *lidarrconfig.Service
|
||||
clientFn func() *lidarr.Client
|
||||
@@ -42,11 +45,11 @@ type Service struct {
|
||||
|
||||
// NewService constructs a Service. Pass nil for clientFn to disable the
|
||||
// Lidarr-using methods (DeleteViaLidarr will return ErrLidarrDisabled).
|
||||
func NewService(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func() *lidarr.Client) *Service {
|
||||
func NewService(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func() *lidarr.Client, dataDir string) *Service {
|
||||
if clientFn == nil {
|
||||
clientFn = func() *lidarr.Client { return nil }
|
||||
}
|
||||
return &Service{pool: pool, lidarrCfg: cfg, clientFn: clientFn}
|
||||
return &Service{pool: pool, lidarrCfg: cfg, clientFn: clientFn, dataDir: dataDir}
|
||||
}
|
||||
|
||||
// Flag inserts or updates a quarantine row for the caller. Re-flagging
|
||||
@@ -245,10 +248,13 @@ func (s *Service) snapshot(ctx context.Context, q *dbq.Queries, track dbq.Track)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteFile removes the track file from disk and the tracks row, then
|
||||
// (via FK ON DELETE CASCADE) clears all per-user quarantine rows for that
|
||||
// track and writes an audit row. If the file deletion fails, the per-user
|
||||
// rows stay so admin can retry. No partial state.
|
||||
// DeleteFile removes the track file from disk and the tracks row (tidying away
|
||||
// an album or artist that leaves empty), then — via FK ON DELETE CASCADE —
|
||||
// clears every per-user quarantine row for that track and writes an audit row.
|
||||
//
|
||||
// If the file cannot be removed, nothing is deleted and the per-user rows stay
|
||||
// so the admin can retry: the error wraps a *library.FileRemoveError, which the
|
||||
// handler turns into an answer naming the cause (#3918). No partial state.
|
||||
func (s *Service) DeleteFile(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, error) {
|
||||
q := dbq.New(s.pool)
|
||||
track, err := q.GetTrackByID(ctx, trackID)
|
||||
@@ -267,7 +273,7 @@ func (s *Service) DeleteFile(ctx context.Context, trackID, adminID pgtype.UUID)
|
||||
if err != nil {
|
||||
return dbq.LidarrQuarantineAction{}, fmt.Errorf("count: %w", err)
|
||||
}
|
||||
if err := library.DeleteTrackFile(ctx, s.pool, trackID); err != nil {
|
||||
if _, err := library.DeleteTrackFile(ctx, s.pool, nil, s.dataDir, trackID); err != nil {
|
||||
return dbq.LidarrQuarantineAction{}, fmt.Errorf("delete file: %w", err)
|
||||
}
|
||||
// tracks row is gone; ON DELETE CASCADE on lidarr_quarantine.track_id
|
||||
|
||||
@@ -87,7 +87,7 @@ func TestFlag_HappyPath(t *testing.T) {
|
||||
user := seedUser(t, pool, "alice")
|
||||
track, _, _ := seedTrack(t, pool, "Bad Track", "abc")
|
||||
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||
row, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "crackly")
|
||||
if err != nil {
|
||||
t.Fatalf("Flag: %v", err)
|
||||
@@ -105,7 +105,7 @@ func TestFlag_UpsertOnSecondFlag(t *testing.T) {
|
||||
user := seedUser(t, pool, "alice")
|
||||
track, _, _ := seedTrack(t, pool, "T", "x")
|
||||
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||
if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "first"); err != nil {
|
||||
t.Fatalf("first flag: %v", err)
|
||||
}
|
||||
@@ -129,7 +129,7 @@ func TestFlag_NonexistentTrackReturnsErrTrackNotFound(t *testing.T) {
|
||||
bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
|
||||
bogus.Valid = true
|
||||
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||
_, err := svc.Flag(context.Background(), user.ID, bogus, "bad_rip", "")
|
||||
if !errors.Is(err, ErrTrackNotFound) {
|
||||
t.Errorf("err = %v, want ErrTrackNotFound", err)
|
||||
@@ -141,7 +141,7 @@ func TestFlag_BadReasonRejected(t *testing.T) {
|
||||
user := seedUser(t, pool, "alice")
|
||||
track, _, _ := seedTrack(t, pool, "T", "x")
|
||||
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||
_, err := svc.Flag(context.Background(), user.ID, track.ID, "garbage", "")
|
||||
if !errors.Is(err, ErrBadReason) {
|
||||
t.Errorf("err = %v, want ErrBadReason", err)
|
||||
@@ -153,7 +153,7 @@ func TestUnflag_DeletesRow(t *testing.T) {
|
||||
user := seedUser(t, pool, "alice")
|
||||
track, _, _ := seedTrack(t, pool, "T", "x")
|
||||
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||
if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", ""); err != nil {
|
||||
t.Fatalf("Flag: %v", err)
|
||||
}
|
||||
@@ -171,7 +171,7 @@ func TestListMine_OrderedNewestFirst(t *testing.T) {
|
||||
t1, _, _ := seedTrack(t, pool, "T1", "x")
|
||||
t2, _, _ := seedTrack(t, pool, "T2", "y")
|
||||
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||
if _, err := svc.Flag(context.Background(), user.ID, t1.ID, "bad_rip", ""); err != nil {
|
||||
t.Fatalf("Flag t1: %v", err)
|
||||
}
|
||||
@@ -199,7 +199,7 @@ func TestListAdminQueue_AggregatesByTrackWithReasonCounts(t *testing.T) {
|
||||
carol := seedUser(t, pool, "carol")
|
||||
track, _, _ := seedTrack(t, pool, "Hot Mess", "abc")
|
||||
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||
if _, err := svc.Flag(context.Background(), alice.ID, track.ID, "bad_rip", ""); err != nil {
|
||||
t.Fatalf("alice flag: %v", err)
|
||||
}
|
||||
@@ -235,7 +235,7 @@ func TestResolve_ClearsRowsAndWritesAudit(t *testing.T) {
|
||||
bob := seedUser(t, pool, "bob")
|
||||
track, _, _ := seedTrack(t, pool, "T", "x")
|
||||
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||
if _, err := svc.Flag(context.Background(), alice.ID, track.ID, "bad_rip", ""); err != nil {
|
||||
t.Fatalf("alice flag: %v", err)
|
||||
}
|
||||
@@ -267,7 +267,7 @@ func TestResolve_NoExistingRowsStillWritesAudit(t *testing.T) {
|
||||
user := seedUser(t, pool, "alice")
|
||||
track, _, _ := seedTrack(t, pool, "T", "x")
|
||||
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||
// No flags applied — resolve a track with zero quarantine rows.
|
||||
audit, err := svc.Resolve(context.Background(), track.ID, user.ID)
|
||||
if err != nil {
|
||||
@@ -291,7 +291,7 @@ func TestResolve_TrackNotFound(t *testing.T) {
|
||||
bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
|
||||
bogus.Valid = true
|
||||
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||
_, err := svc.Resolve(context.Background(), bogus, user.ID)
|
||||
if !errors.Is(err, ErrTrackNotFound) {
|
||||
t.Errorf("err = %v, want ErrTrackNotFound", err)
|
||||
@@ -315,7 +315,7 @@ func TestDeleteFile_RemovesFileAndAuditsAffected(t *testing.T) {
|
||||
DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3",
|
||||
})
|
||||
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||
if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", ""); err != nil {
|
||||
t.Fatalf("Flag: %v", err)
|
||||
}
|
||||
@@ -366,7 +366,7 @@ func TestDeleteViaLidarr_FullCascade(t *testing.T) {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") }
|
||||
svc := NewService(pool, cfg, clientFn)
|
||||
svc := NewService(pool, cfg, clientFn, "")
|
||||
|
||||
q := dbq.New(pool)
|
||||
artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"})
|
||||
@@ -424,7 +424,7 @@ func TestDeleteViaLidarr_LidarrDisabled(t *testing.T) {
|
||||
user := seedUser(t, pool, "alice")
|
||||
track, _, _ := seedTrack(t, pool, "T", "x")
|
||||
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||
_, _, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID)
|
||||
if !errors.Is(err, ErrLidarrDisabled) {
|
||||
t.Errorf("err = %v, want ErrLidarrDisabled", err)
|
||||
@@ -456,7 +456,7 @@ func TestDeleteViaLidarr_AlbumMBIDMissing(t *testing.T) {
|
||||
cfg := lidarrconfig.New(pool)
|
||||
_ = cfg.Save(context.Background(), lidarrconfig.Config{Enabled: true, BaseURL: stub.URL, APIKey: "k"})
|
||||
clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") }
|
||||
svc := NewService(pool, cfg, clientFn)
|
||||
svc := NewService(pool, cfg, clientFn, "")
|
||||
_, _ = svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "")
|
||||
|
||||
_, _, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID)
|
||||
@@ -481,7 +481,7 @@ func TestDeleteViaLidarr_LidarrAlbumNotFound(t *testing.T) {
|
||||
cfg := lidarrconfig.New(pool)
|
||||
_ = cfg.Save(context.Background(), lidarrconfig.Config{Enabled: true, BaseURL: stub.URL, APIKey: "k"})
|
||||
clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") }
|
||||
svc := NewService(pool, cfg, clientFn)
|
||||
svc := NewService(pool, cfg, clientFn, "")
|
||||
|
||||
track, _, _ := seedTrack(t, pool, "T", "x")
|
||||
_, _ = svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "")
|
||||
@@ -499,7 +499,7 @@ func TestDeleteFile_TrackNotFound(t *testing.T) {
|
||||
bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
|
||||
bogus.Valid = true
|
||||
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil)
|
||||
svc := NewService(pool, lidarrconfig.New(pool), nil, "")
|
||||
_, err := svc.DeleteFile(context.Background(), bogus, user.ID)
|
||||
if !errors.Is(err, ErrTrackNotFound) {
|
||||
t.Errorf("err = %v, want ErrTrackNotFound", err)
|
||||
@@ -521,7 +521,7 @@ func TestDeleteViaLidarr_TrackNotFound(t *testing.T) {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") }
|
||||
svc := NewService(pool, cfg, clientFn)
|
||||
svc := NewService(pool, cfg, clientFn, "")
|
||||
|
||||
var bogus pgtype.UUID
|
||||
bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
|
||||
|
||||
+114
-15
@@ -219,7 +219,30 @@ var (
|
||||
// uniform with radio pending trend data.
|
||||
ContextTimeWeight: 1.0,
|
||||
}
|
||||
// Songs-like's own profile (#3881). Pre-push literal only; shipped
|
||||
// defaults live in recsettings.ShippedSongsLikeWeights and must stay in
|
||||
// sync with it, exactly as systemMixWeights does above.
|
||||
//
|
||||
// SimilarityWeight dominates here and every seed-INDEPENDENT term is
|
||||
// demoted, which is the whole difference between this surface and For-You.
|
||||
// See ShippedSongsLikeWeights for the property the numbers encode.
|
||||
songsLikeWeights = recommendation.ScoringWeights{
|
||||
BaseWeight: 1.0,
|
||||
LikeBoost: 0.5,
|
||||
RecencyWeight: 0.25,
|
||||
SkipPenalty: 2.0,
|
||||
JitterMagnitude: 0.05,
|
||||
ContextWeight: 0.5,
|
||||
SimilarityWeight: 4.0,
|
||||
TasteWeight: 0.25,
|
||||
ContextTimeWeight: 0.5,
|
||||
}
|
||||
systemTasteConfig = taste.DefaultConfig()
|
||||
|
||||
// Sizes the candidate pool to the library (#3880). Cached because the
|
||||
// count is a full table scan and the daily build runs it once per user;
|
||||
// within the TTL every user in a build shares one count.
|
||||
systemLibrarySize = recommendation.NewLibrarySize(nil)
|
||||
)
|
||||
|
||||
// SetSystemMixWeights installs the current daily_mix scoring weights.
|
||||
@@ -230,6 +253,20 @@ func SetSystemMixWeights(w recommendation.ScoringWeights) {
|
||||
systemMixWeights = w
|
||||
}
|
||||
|
||||
// SetSongsLikeWeights installs the songs_like scoring profile (#3881).
|
||||
// Same push model as SetSystemMixWeights — recsettings calls it on boot and
|
||||
// after every knob turn, so a tuning change takes effect on the next daily
|
||||
// build with no restart.
|
||||
//
|
||||
// Separate from systemMixWeights because Songs-like and For-You want opposite
|
||||
// things: For-You roams, Songs-like must not. Sharing one profile is what made
|
||||
// "Songs like X" wander.
|
||||
func SetSongsLikeWeights(w recommendation.ScoringWeights) {
|
||||
systemTuningMu.Lock()
|
||||
defer systemTuningMu.Unlock()
|
||||
songsLikeWeights = w
|
||||
}
|
||||
|
||||
// SetTasteConfig installs the taste-profile build configuration
|
||||
// (half-life + engagement curve, #1250). Same push model as
|
||||
// SetSystemMixWeights.
|
||||
@@ -239,6 +276,23 @@ func SetTasteConfig(c taste.Config) {
|
||||
systemTasteConfig = c
|
||||
}
|
||||
|
||||
// dailyOrderSeed is the value the randomised candidate arms order by (#3889).
|
||||
//
|
||||
// Per (user, day) so a same-day rebuild draws the SAME set — which is what
|
||||
// TestBuildSystemPlaylists_DailyNonceDeterminism asserts and what those arms
|
||||
// only ever achieved by accident before, when their limits happened to exceed
|
||||
// the eligible rows. It changes on the day boundary, so the mixes still move
|
||||
// daily.
|
||||
func dailyOrderSeed(userID pgtype.UUID, dateStr string) string {
|
||||
return uuidStringPL(userID) + ":" + dateStr
|
||||
}
|
||||
|
||||
func currentSongsLikeWeights() recommendation.ScoringWeights {
|
||||
systemTuningMu.RLock()
|
||||
defer systemTuningMu.RUnlock()
|
||||
return songsLikeWeights
|
||||
}
|
||||
|
||||
func currentSystemMixWeights() recommendation.ScoringWeights {
|
||||
systemTuningMu.RLock()
|
||||
defer systemTuningMu.RUnlock()
|
||||
@@ -392,7 +446,13 @@ func pickWeightedTail(tailPool []recommendation.Candidate, dateStr string, tailN
|
||||
// tieBreakHash). The scoring RNG is seeded by userIDHash so jitter is
|
||||
// deterministic per (user, day) but rotates across days. Pure — no
|
||||
// truncation, no cap.
|
||||
func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time) []recommendation.Candidate {
|
||||
// weights is a parameter rather than a read of currentSystemMixWeights()
|
||||
// because this sort IS the selection: the caller caps and truncates in the
|
||||
// order this returns, so whatever profile ranks here decides which tracks
|
||||
// reach the playlist. Scoring with daily_mix here and re-scoring with
|
||||
// songs_like afterwards would have let the new profile relabel tracks it had
|
||||
// no part in choosing — inert where it matters (#3881).
|
||||
func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, weights recommendation.ScoringWeights) []recommendation.Candidate {
|
||||
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
||||
type scored struct {
|
||||
c recommendation.Candidate
|
||||
@@ -411,7 +471,6 @@ func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
return uuidLessPL(ordered[i].Track.ID, ordered[j].Track.ID)
|
||||
})
|
||||
weights := currentSystemMixWeights()
|
||||
pairs := make([]scored, len(ordered))
|
||||
for i, c := range ordered {
|
||||
pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, weights, now, rng.Float64)}
|
||||
@@ -581,6 +640,7 @@ func produceForYou(
|
||||
zeroVec,
|
||||
seeds,
|
||||
systemForYouSourceLimits(),
|
||||
dailyOrderSeed(userID, dateStr),
|
||||
)
|
||||
if cerr != nil {
|
||||
logger.Warn("system playlist: for-you candidates load failed for seed; continuing",
|
||||
@@ -632,6 +692,13 @@ func produceSeedMixes(
|
||||
seedPool := pickSeedArtistsFromRows(seedRowsLocal)
|
||||
seeds := pickSeedArtistsForDay(seedPool, userID, dateStr)
|
||||
|
||||
// Once per build rather than once per seed artist — six mixes would
|
||||
// otherwise mean six full-table counts for a number that cannot have
|
||||
// changed between them.
|
||||
librarySize := systemLibrarySize.Get(ctx, func(c context.Context) (int64, error) {
|
||||
return recommendation.CountLibraryTracks(c, q)
|
||||
})
|
||||
|
||||
out := make([]builtPlaylist, 0, len(seeds))
|
||||
for _, artistID := range seeds {
|
||||
artistRow, aerr := q.GetArtistByID(ctx, artistID)
|
||||
@@ -647,23 +714,47 @@ func produceSeedMixes(
|
||||
continue
|
||||
}
|
||||
zeroVec := recommendation.SessionVector{Seed: true}
|
||||
// Songs-like's own pool shape, not the default (#3881). Same total
|
||||
// size; the composition shifts toward arms that actually measure
|
||||
// distance from the seed. The default gave ~29% of candidates a
|
||||
// sim_score of literally 0.
|
||||
//
|
||||
// Then scaled to the library (#3880): a bigger collection should put
|
||||
// more genuinely-similar candidates in reach, not the same ~170
|
||||
// regardless. The weights still rank sim_score-0 arms last, so the
|
||||
// growth reaches coherence rather than working against it.
|
||||
cands, cerr := recommendation.LoadCandidatesFromSimilarity(
|
||||
ctx, q, userID, seedTrack, 1, zeroVec, []pgtype.UUID{seedTrack},
|
||||
recommendation.DefaultCandidateSourceLimits(),
|
||||
recommendation.ScaleForLibrary(
|
||||
recommendation.SongsLikeCandidateSourceLimits(), librarySize,
|
||||
),
|
||||
dailyOrderSeed(userID, dateStr),
|
||||
)
|
||||
if cerr != nil {
|
||||
logger.Warn("system playlist: seed candidates load failed; skipping",
|
||||
"artist_id", uuidStringPL(artistID), "err", cerr)
|
||||
continue
|
||||
}
|
||||
// "Songs like X" excludes X's own songs.
|
||||
filtered := make([]recommendation.Candidate, 0, len(cands))
|
||||
for _, c := range cands {
|
||||
if !pgtypeUUIDEqual(c.Track.ArtistID, artistID) {
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
}
|
||||
tracks := pickTopN(filtered, userID, dateStr, now, systemMixLength)
|
||||
// The seed artist's own songs are ELIGIBLE here, deliberately.
|
||||
//
|
||||
// This used to filter them out — "Songs like X excludes X's own
|
||||
// songs" — which reads as obviously right and is not. The seed is a
|
||||
// TRACK, and the tracks most likely to sound like it are usually the
|
||||
// rest of that artist's catalogue; excluding them threw away the
|
||||
// nearest neighbours of the very thing the mix is built around, and
|
||||
// then reached further out to replace them. On a surface whose whole
|
||||
// job is staying in one neighbourhood, that is backwards.
|
||||
//
|
||||
// Operator, 2026-09-10: "it should also be able to include music from
|
||||
// the same artist."
|
||||
//
|
||||
// Domination is bounded by the cap rather than by exclusion, which is
|
||||
// the distinction that makes this safe: capCandidatesByAlbumAndArtist
|
||||
// inside pickTopN allows at most discoverMaxTracksPerArtist (3) of a
|
||||
// 25-track mix — 12%, a presence rather than a takeover. The seed
|
||||
// track itself still cannot appear; it is passed as an exclusion to
|
||||
// LoadCandidatesFromSimilarity above.
|
||||
tracks := pickTopN(cands, userID, dateStr, now, systemMixLength)
|
||||
if len(tracks) == 0 {
|
||||
continue
|
||||
}
|
||||
@@ -838,13 +929,20 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.
|
||||
// truncates to n. Used by Songs-like-X (and as the fallback inside
|
||||
// pickHeadAndTail for small pools).
|
||||
func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, n int) []rankedCandidate {
|
||||
sorted := scoreAndSortCandidates(cands, userID, dateStr, now)
|
||||
// songs_like, not daily_mix (#3881). produceSeedMixes is this function's
|
||||
// only caller, so the switch moves exactly one surface — For-You ranks
|
||||
// through pickHeadAndTail and keeps the broader daily_mix profile.
|
||||
//
|
||||
// The SAME profile does the selection sort and the final score. Passing
|
||||
// one and using the other is the subtle version of this bug: the playlist
|
||||
// would still be chosen by daily_mix and merely wear songs_like numbers.
|
||||
weights := currentSongsLikeWeights()
|
||||
sorted := scoreAndSortCandidates(cands, userID, dateStr, now, weights)
|
||||
capped := capCandidatesByAlbumAndArtist(sorted)
|
||||
if len(capped) > n {
|
||||
capped = capped[:n]
|
||||
}
|
||||
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
||||
weights := currentSystemMixWeights()
|
||||
out := make([]rankedCandidate, len(capped))
|
||||
for i, c := range capped {
|
||||
out[i] = rankedCandidate{
|
||||
@@ -873,10 +971,11 @@ func pickHeadAndTail(
|
||||
cands []recommendation.Candidate, seedOf map[pgtype.UUID]int, numSeeds int,
|
||||
userID pgtype.UUID, dateStr string, now time.Time, headN, tailN int,
|
||||
) []rankedCandidate {
|
||||
sorted := scoreAndSortCandidates(cands, userID, dateStr, now)
|
||||
// daily_mix — For-You is the broad surface and keeps the roaming profile.
|
||||
weights := currentSystemMixWeights()
|
||||
sorted := scoreAndSortCandidates(cands, userID, dateStr, now, weights)
|
||||
capped := capCandidatesByAlbumAndArtist(sorted)
|
||||
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
||||
weights := currentSystemMixWeights()
|
||||
|
||||
total := headN + tailN
|
||||
if len(capped) <= total {
|
||||
|
||||
@@ -2,8 +2,10 @@ package playlists_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -287,6 +289,141 @@ func TestBuildSystemPlaylists_Concurrency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// seedSharedArtistLibrary seeds artists that genuinely OWN several tracks.
|
||||
//
|
||||
// seedActiveLibrary cannot be used for this: its helper documents that
|
||||
// "artist and album are not deduplicated across calls (mbid-less upsert)",
|
||||
// so every track gets its own artist row and a seed artist always has
|
||||
// exactly one track — the seed itself, which is excluded. A same-artist
|
||||
// assertion against that fixture can never pass no matter what the code
|
||||
// does, which is how the first version of this test failed.
|
||||
//
|
||||
// Albums are not deduplicated either, for the same mbid-less reason, so each
|
||||
// track ends up under its own album row however the titles are written. That
|
||||
// is convenient here rather than a problem: it means the per-ALBUM cap (2)
|
||||
// never binds, and the per-ARTIST cap (3) is unambiguously the thing under
|
||||
// test. Do not "fix" the album titles into something shared without checking
|
||||
// which cap you are then measuring.
|
||||
func seedSharedArtistLibrary(
|
||||
t *testing.T, pool *pgxpool.Pool, name string, numArtists, tracksPerArtist int,
|
||||
) (dbq.User, []pgtype.UUID) {
|
||||
t.Helper()
|
||||
q := dbq.New(pool)
|
||||
ctx := context.Background()
|
||||
u := seedUser(t, pool, name)
|
||||
now := time.Now().UTC()
|
||||
dir := t.TempDir()
|
||||
|
||||
artistIDs := make([]pgtype.UUID, 0, numArtists)
|
||||
for a := 0; a < numArtists; a++ {
|
||||
artistName := name + "-shared-" + string(rune('A'+a))
|
||||
ar, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: artistName, SortName: artistName})
|
||||
if err != nil {
|
||||
t.Fatalf("seed artist: %v", err)
|
||||
}
|
||||
artistIDs = append(artistIDs, ar.ID)
|
||||
|
||||
for k := 0; k < tracksPerArtist; k++ {
|
||||
albumTitle := fmt.Sprintf("%s - Album %d", artistName, k/2)
|
||||
al, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{
|
||||
Title: albumTitle, SortTitle: albumTitle, ArtistID: ar.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed album: %v", err)
|
||||
}
|
||||
tk, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
|
||||
Title: fmt.Sprintf("%s-t%d", artistName, k), AlbumID: al.ID, ArtistID: ar.ID,
|
||||
DurationMs: 1000,
|
||||
FilePath: filepath.Join(dir, fmt.Sprintf("%s-%d-%d.mp3", name, a, k)),
|
||||
FileSize: 100, FileFormat: "mp3",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed track: %v", err)
|
||||
}
|
||||
// Well clear of the recently-played exclusion window, which uses
|
||||
// the DATABASE clock rather than the build's `now`.
|
||||
for pl := 0; pl < 3; pl++ {
|
||||
seedPlayEvent(t, pool, u.ID, tk.ID,
|
||||
now.Add(-time.Duration(24+a*10+k+pl)*time.Hour), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
return u, artistIDs
|
||||
}
|
||||
|
||||
// The seed artist's own tracks are ELIGIBLE for its "Songs like" mix, and are
|
||||
// bounded by the diversity cap rather than excluded outright (#3881).
|
||||
//
|
||||
// produceSeedMixes used to filter them out — "Songs like X excludes X's own
|
||||
// songs" — which threw away the nearest neighbours of the seed TRACK and then
|
||||
// reached further out to replace them. Operator, 2026-09-10: "it should also
|
||||
// be able to include music from the same artist."
|
||||
//
|
||||
// Asserted end-to-end rather than by reading the source, because the guard has
|
||||
// to survive the filter coming back in a different shape — and because an
|
||||
// absence check would now match the comment explaining why the filter is gone.
|
||||
func TestBuildSystemPlaylists_SongsLikeIncludesItsSeedArtist(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
logger := discardLogger()
|
||||
u, _ := seedSharedArtistLibrary(t, pool, "seedartist", 4, 6)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
if err := playlists.BuildSystemPlaylists(ctx, pool, logger, u.ID, now, t.TempDir()); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT count(*) FILTER (WHERE t.artist_id = p.seed_artist_id) AS own,
|
||||
count(*) AS total
|
||||
FROM playlists p
|
||||
JOIN playlist_tracks pt ON pt.playlist_id = p.id
|
||||
JOIN tracks t ON t.id = pt.track_id
|
||||
WHERE p.user_id = $1 AND p.system_variant = 'songs_like_artist'
|
||||
GROUP BY p.id
|
||||
`, u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("query: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// discoverMaxTracksPerArtist, which this package_test cannot reference.
|
||||
// Duplicated deliberately: if the cap moves, this failing is the point.
|
||||
const maxPerArtist = 3
|
||||
|
||||
mixes, withOwn := 0, 0
|
||||
for rows.Next() {
|
||||
var own, total int
|
||||
if err := rows.Scan(&own, &total); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
mixes++
|
||||
if own > 0 {
|
||||
withOwn++
|
||||
}
|
||||
// The bound is what makes inclusion safe. Without it, "include the
|
||||
// seed artist" becomes "the mix is mostly the seed artist", which is
|
||||
// the radio failure (#3882) arriving on a different surface.
|
||||
if own > maxPerArtist {
|
||||
t.Errorf("a songs_like mix carries %d tracks by its own seed artist "+
|
||||
"out of %d; the per-artist cap (%d) is not being applied",
|
||||
own, total, maxPerArtist)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("rows: %v", err)
|
||||
}
|
||||
|
||||
if mixes == 0 {
|
||||
t.Fatal("no songs_like_artist mixes were built, so this test asserts nothing")
|
||||
}
|
||||
if withOwn == 0 {
|
||||
t.Errorf("none of the %d songs_like mixes contains a single track by its own "+
|
||||
"seed artist — the tracks most likely to sound like the seed are being "+
|
||||
"excluded from the surface whose job is sounding like the seed", mixes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPlaylists_DailyNonceDeterminism(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
logger := discardLogger()
|
||||
|
||||
@@ -102,6 +102,7 @@ func buildYouMightLike(
|
||||
cands, err := recommendation.LoadCandidatesFromSimilarity(
|
||||
ctx, q, userID, seed, 1, zeroVec,
|
||||
[]pgtype.UUID{seed}, ymlLimits,
|
||||
dailyOrderSeed(userID, dateStr),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Warn("you-might-like: candidate load failed; skipping",
|
||||
|
||||
@@ -105,11 +105,87 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits {
|
||||
}
|
||||
}
|
||||
|
||||
// SongsLikeCandidateSourceLimits is the pool shape for "Songs like {X}"
|
||||
// (#3881). Same total size as the default (~170) — the composition is what
|
||||
// changes, shifted hard toward arms that actually measure distance from the
|
||||
// seed.
|
||||
//
|
||||
// The surface answers "what sounds like THIS", and it shared the default
|
||||
// pool with For-You, which answers the much broader "what will they enjoy
|
||||
// today". Under the default, 50 of ~170 candidates carried sim_score = 0 by
|
||||
// construction — `taste_overlap` (tracks by the user's top taste artists) and
|
||||
// `random_fill` (literally any track not already in the pool), both of which
|
||||
// are seed-INDEPENDENT. Nearly a third of the pool had no relationship to the
|
||||
// seed at all, and the operator saw it: "I was getting a seeming wide variety
|
||||
// of music from each one when I was hoping to stay in a certain neighborhood."
|
||||
//
|
||||
// TIERED, per rule 131 — a system mix degrades, it never vanishes:
|
||||
//
|
||||
// tier 1 lb_similar real track-level similarity. The exact promise.
|
||||
// tier 2 similar_artist, tag_overlap, coplay, likes_overlap
|
||||
// seed-RELATED but weaker signal.
|
||||
// tier 3 taste_overlap, random_fill
|
||||
// seed-independent. The floor, and nothing more.
|
||||
//
|
||||
// The tiering is enforced by SCORE rather than by a fallback ladder: tier-3
|
||||
// arms carry sim_score 0, so under SongsLike weights (SimilarityWeight 4.0,
|
||||
// everything seed-independent demoted) they rank below any real match and
|
||||
// surface only when tiers 1–2 cannot fill the mix. That is the rule's
|
||||
// "fill from tier 1 first, reach down only when a tier cannot fill".
|
||||
//
|
||||
// Which is exactly why tier 3 is REDUCED rather than removed. Zeroing those
|
||||
// two arms was the first instinct and it is the vanish-or-nothing shape rule
|
||||
// 131 exists to forbid: a seed whose artist has thin ListenBrainz coverage
|
||||
// would produce a short mix or none at all, and "no playlist" is a worse
|
||||
// answer than "a few tracks further from the seed than we would like".
|
||||
//
|
||||
// The seed-independent arms are trimmed hardest, because on this surface they
|
||||
// are noise: `taste_overlap` (tracks by the user's top taste artists) and
|
||||
// `random_fill` (any track not already in the pool) both carry
|
||||
// `0.0::float8 AS sim_score`, so nearly a third of the default pool had no
|
||||
// relationship to the seed at all.
|
||||
//
|
||||
// THESE TRIMS WERE BLOCKED UNTIL #3889. `likes_overlap` and `random_fill`
|
||||
// used to end in a bare `ORDER BY random()`, which made their output a stable
|
||||
// SET only while the limit exceeded the eligible rows — so SHRINKING them
|
||||
// changed pool membership between same-day rebuilds and broke daily
|
||||
// determinism. Those arms now order by md5(id || seed), so a smaller limit
|
||||
// takes a smaller but REPRODUCIBLE slice, and the trim is safe.
|
||||
//
|
||||
// Reduced, never removed. Rule 131: the two seed-independent arms are the
|
||||
// tier-3 floor, and zeroing them would leave a seed with thin ListenBrainz
|
||||
// coverage producing a short mix or none at all. The weights (SimilarityWeight
|
||||
// 4.0, everything seed-independent demoted) keep them ranked last, so they
|
||||
// surface only when the closer tiers cannot fill the mix.
|
||||
//
|
||||
// likes_overlap is cut hardest of the tier-2 arms for a specific reason: its
|
||||
// SQL assigns a FLAT 0.6 sim_score (recommendation.sql) rather than measuring
|
||||
// anything. It is a collaborative signal wearing similarity's clothes, and a
|
||||
// raised SimilarityWeight amplifies it — if real ListenBrainz scores commonly
|
||||
// land below 0.6 it would outrank genuine matches. Halved pending the
|
||||
// fill-rate measurement in #3879; the honest fix is to stop it claiming a
|
||||
// similarity score it never computed.
|
||||
func SongsLikeCandidateSourceLimits() CandidateSourceLimits {
|
||||
return CandidateSourceLimits{
|
||||
LBSimilar: 60, // tier 1 — doubled; the only arm that measures the seed
|
||||
SimilarArtist: 40, // tier 2 — raised; growing is always safe
|
||||
TagOverlap: 20, // tier 2
|
||||
UserCoplay: 20, // tier 2
|
||||
LikesOverlap: 10, // tier 2, halved — flat 0.6 sim_score, see above
|
||||
TasteOverlap: 10, // tier 3 floor — halved, not removed
|
||||
RandomFill: 10, // tier 3 floor — cut hard, never to zero
|
||||
}
|
||||
}
|
||||
|
||||
// LoadCandidatesFromSimilarity is M4c's primary candidate-pool loader.
|
||||
// 5-way SQL UNION (LB-similar / similar-artist tracks / MB-tag overlap /
|
||||
// likes-overlap / random fill) + dedup-by-max sim_score. Returns
|
||||
// []Candidate (same shape as LoadCandidates) so Shuffle is unchanged.
|
||||
//
|
||||
// orderSeed decides whether the randomised arms repeat their draw — see
|
||||
// Column12 below and #3889. Pass a stable per-(user, day) value where the
|
||||
// selection must be reproducible, and a varying one where it should not be.
|
||||
//
|
||||
// Caller (radio handler) falls back to LoadCandidates on error.
|
||||
func LoadCandidatesFromSimilarity(
|
||||
ctx context.Context,
|
||||
@@ -119,6 +195,7 @@ func LoadCandidatesFromSimilarity(
|
||||
currentVector SessionVector,
|
||||
exclude []pgtype.UUID,
|
||||
limits CandidateSourceLimits,
|
||||
orderSeed string,
|
||||
) ([]Candidate, error) {
|
||||
if exclude == nil {
|
||||
exclude = []pgtype.UUID{}
|
||||
@@ -135,6 +212,12 @@ func LoadCandidatesFromSimilarity(
|
||||
Limit_5: int32(limits.RandomFill),
|
||||
Limit_6: int32(limits.TasteOverlap),
|
||||
Limit_7: int32(limits.UserCoplay),
|
||||
// #3889. Four arms used to end in a bare ORDER BY random(), which made
|
||||
// their output a stable SET only while the limit exceeded the eligible
|
||||
// rows. They now order by md5(id || this), so the caller decides
|
||||
// whether the draw repeats: a per-(user, day) seed for the system
|
||||
// mixes that promise daily determinism, a fresh one per radio request.
|
||||
Column12: orderSeed,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -2,6 +2,10 @@ package recommendation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
@@ -50,7 +54,7 @@ func TestLoadCandidatesFromSimilarity_LBSimilarSourceContributes(t *testing.T) {
|
||||
target := f.tracks[1]
|
||||
helperLBSimilarity(t, f, seed.ID, target.ID, 0.85)
|
||||
got, err := LoadCandidatesFromSimilarity(
|
||||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
|
||||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), "test-seed",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
@@ -82,7 +86,7 @@ func TestLoadCandidatesFromSimilarity_SimilarArtistTracksContribute(t *testing.T
|
||||
})
|
||||
helperArtistSimilarity(t, f, seed.ArtistID, otherArtist.ID, 0.8)
|
||||
got, err := LoadCandidatesFromSimilarity(
|
||||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
|
||||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), "test-seed",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
@@ -106,7 +110,7 @@ func TestLoadCandidatesFromSimilarity_TagOverlapContributes(t *testing.T) {
|
||||
helperSetTrackGenre(t, f, seed.ID, "Rock; Pop")
|
||||
helperSetTrackGenre(t, f, target.ID, "Rock")
|
||||
got, err := LoadCandidatesFromSimilarity(
|
||||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
|
||||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), "test-seed",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
@@ -133,7 +137,7 @@ func TestLoadCandidatesFromSimilarity_LikesOverlapContributes(t *testing.T) {
|
||||
t.Fatalf("like: %v", err)
|
||||
}
|
||||
got, err := LoadCandidatesFromSimilarity(
|
||||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
|
||||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), "test-seed",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
@@ -155,7 +159,7 @@ func TestLoadCandidatesFromSimilarity_RandomFillReturnsTracks(t *testing.T) {
|
||||
f := newFixture(t, 10) // 10 tracks; no similarity data
|
||||
seed := f.tracks[0]
|
||||
got, err := LoadCandidatesFromSimilarity(
|
||||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
|
||||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), "test-seed",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
@@ -176,7 +180,7 @@ func TestLoadCandidatesFromSimilarity_ExcludeListRespected(t *testing.T) {
|
||||
excluded := f.tracks[1].ID
|
||||
got, err := LoadCandidatesFromSimilarity(
|
||||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true},
|
||||
[]pgtype.UUID{excluded}, defaultLimits(),
|
||||
[]pgtype.UUID{excluded}, defaultLimits(), "test-seed",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
@@ -192,7 +196,7 @@ func TestLoadCandidatesFromSimilarity_SeedAlwaysExcluded(t *testing.T) {
|
||||
f := newFixture(t, 5)
|
||||
seed := f.tracks[0]
|
||||
got, err := LoadCandidatesFromSimilarity(
|
||||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
|
||||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), "test-seed",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
@@ -222,7 +226,7 @@ func TestLoadCandidatesFromSimilarity_RecentlyPlayedExcluded(t *testing.T) {
|
||||
t.Fatalf("play_event: %v", err)
|
||||
}
|
||||
got, err := LoadCandidatesFromSimilarity(
|
||||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
|
||||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), "test-seed",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
@@ -242,7 +246,7 @@ func TestLoadCandidatesFromSimilarity_DedupTakesMaxScore(t *testing.T) {
|
||||
helperSetTrackGenre(t, f, target.ID, "Rock") // jaccard 1/1 = 1.0 from tag-overlap
|
||||
helperLBSimilarity(t, f, seed.ID, target.ID, 0.5) // weaker LB signal
|
||||
got, err := LoadCandidatesFromSimilarity(
|
||||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
|
||||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), "test-seed",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
@@ -295,7 +299,7 @@ func TestLoadCandidatesFromSimilarity_TasteOverlapArm(t *testing.T) {
|
||||
// Only the taste_overlap arm is enabled.
|
||||
limits := CandidateSourceLimits{TasteOverlap: 10}
|
||||
got, err := LoadCandidatesFromSimilarity(
|
||||
ctx, f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, limits,
|
||||
ctx, f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, limits, "test-seed",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
@@ -321,7 +325,7 @@ func TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError(t *testing.T) {
|
||||
f := newFixture(t, 1) // just the seed
|
||||
seed := f.tracks[0]
|
||||
got, err := LoadCandidatesFromSimilarity(
|
||||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
|
||||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), "test-seed",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
@@ -331,3 +335,97 @@ func TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError(t *testing.T) {
|
||||
t.Errorf("got %d candidates from seed-only library, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// The randomised arms must draw REPRODUCIBLY for a given seed (#3889).
|
||||
//
|
||||
// Four arms used to end in a bare `ORDER BY random()`. That returned a stable
|
||||
// set only while the arm's LIMIT exceeded the rows eligible for it — at that
|
||||
// point it returned all of them and the order stopped mattering, because the
|
||||
// caller sorts by track id before scoring. Below that threshold it returned a
|
||||
// random SUBSET, so two calls drew different candidates.
|
||||
//
|
||||
// It therefore held by ACCIDENT, and only for libraries smaller than the
|
||||
// limits. Any real library is larger, so same-day rebuilds had been drawing
|
||||
// different mixes since the arm was written — invisible, because a mix that
|
||||
// changes after a refresh looks like a feature.
|
||||
//
|
||||
// Limits deliberately smaller than the fixture, because that is the only
|
||||
// regime where the bug existed at all: with limits above the eligible count
|
||||
// the old code passes this too.
|
||||
func TestLoadCandidatesFromSimilarity_SameSeedDrawsTheSameSet(t *testing.T) {
|
||||
f := newFixture(t, 12)
|
||||
seed := f.tracks[0]
|
||||
|
||||
tight := CandidateSourceLimits{
|
||||
LBSimilar: 2, SimilarArtist: 2, TagOverlap: 2,
|
||||
LikesOverlap: 2, RandomFill: 3, TasteOverlap: 2, UserCoplay: 2,
|
||||
}
|
||||
ids := func(cs []Candidate) []string {
|
||||
out := make([]string, 0, len(cs))
|
||||
for _, c := range cs {
|
||||
out = append(out, fmt.Sprintf("%x", c.Track.ID.Bytes))
|
||||
}
|
||||
sort.Strings(out) // membership, not order — order is settled downstream
|
||||
return out
|
||||
}
|
||||
|
||||
first, err := LoadCandidatesFromSimilarity(
|
||||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, tight, "day-one",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if len(first) == 0 {
|
||||
t.Fatal("no candidates, so this test asserts nothing")
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
again, err := LoadCandidatesFromSimilarity(
|
||||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, tight, "day-one",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("load %d: %v", i, err)
|
||||
}
|
||||
if !reflect.DeepEqual(ids(first), ids(again)) {
|
||||
t.Fatalf("same seed drew a different set on call %d:\n first %v\n again %v",
|
||||
i, ids(first), ids(again))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ...and a different seed is free to draw differently, or the ordering would
|
||||
// be fixed rather than seeded and every day would serve the same mix.
|
||||
//
|
||||
// Asserted as "not pinned to one answer" rather than "always differs": with a
|
||||
// small fixture two seeds can legitimately collide, so requiring a difference
|
||||
// on any single pair would be flaky. Several seeds producing exactly one
|
||||
// distinct set is the real regression — that is what a constant ORDER BY
|
||||
// looks like.
|
||||
func TestLoadCandidatesFromSimilarity_DifferentSeedsCanDrawDifferently(t *testing.T) {
|
||||
f := newFixture(t, 12)
|
||||
seed := f.tracks[0]
|
||||
tight := CandidateSourceLimits{
|
||||
LBSimilar: 2, SimilarArtist: 2, TagOverlap: 2,
|
||||
LikesOverlap: 2, RandomFill: 3, TasteOverlap: 2, UserCoplay: 2,
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, orderSeed := range []string{"a", "b", "c", "d", "e", "f"} {
|
||||
cs, err := LoadCandidatesFromSimilarity(
|
||||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, tight, orderSeed,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("load %q: %v", orderSeed, err)
|
||||
}
|
||||
ids := make([]string, 0, len(cs))
|
||||
for _, c := range cs {
|
||||
ids = append(ids, fmt.Sprintf("%x", c.Track.ID.Bytes))
|
||||
}
|
||||
sort.Strings(ids)
|
||||
seen[strings.Join(ids, ",")] = true
|
||||
}
|
||||
if len(seen) < 2 {
|
||||
t.Errorf("six different seeds produced %d distinct set(s); the ordering is not "+
|
||||
"varying with the seed at all", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// Scaling the candidate pool to the library (#3880).
|
||||
//
|
||||
// DefaultCandidateSourceLimits returns what its own comment calls "the v1
|
||||
// hardcoded constants per spec" — ~170 candidates, identical for a 500-track
|
||||
// library and a 100,000-track one. Operator, 2026-09-10: "is the pool that we
|
||||
// draw from somehow scaled to the amount of music in the library... my earlier
|
||||
// understanding of the tuning and work may have been skewed by what was in my
|
||||
// library."
|
||||
//
|
||||
// It was not, and the consequence compounds. The pool samples a shrinking
|
||||
// FRACTION of the library as it grows — 17% of 1,000 tracks, 1.7% of 10,000,
|
||||
// 0.17% of 100,000 — so the ceiling on how much of a collection can ever
|
||||
// surface stays flat while the collection does not. RandomFill, whose entire
|
||||
// job is exploration, becomes a thinner and noisier slice of a more diverse
|
||||
// corpus at exactly the moment diversity rises.
|
||||
|
||||
const (
|
||||
// libraryScaleReference is the library size at which the base limits
|
||||
// apply unchanged. Below it nothing scales, so small libraries keep
|
||||
// today's behaviour exactly.
|
||||
//
|
||||
// ASSUMED, NOT MEASURED. The size the v1 constants were actually tuned
|
||||
// against is unrecorded; 5,000 is a plausible mid-size library and a
|
||||
// deliberately conservative place to start growing. #3879 should replace
|
||||
// this with the real number, and until it does, this constant is the
|
||||
// single thing to change.
|
||||
libraryScaleReference = 5000
|
||||
|
||||
// maxLibraryScale bounds growth so a very large library does not turn
|
||||
// every recommendation query into a huge scan. At 4x the pool tops out
|
||||
// around 500 candidates, which is still cheap to score in memory.
|
||||
maxLibraryScale = 4.0
|
||||
)
|
||||
|
||||
// libraryScale is sqrt rather than linear on purpose. Linear growth would put
|
||||
// a 100,000-track library at a 3,400-candidate pool — a slow query, slow
|
||||
// scoring, and far past the point where more candidates improve the answer.
|
||||
// Square-root growth keeps the pool meaningfully proportional while staying
|
||||
// bounded: 1x at the reference, 2x at four times it, 4x at sixteen times.
|
||||
//
|
||||
// Never returns below 1: the base limits are a floor, not a midpoint. That
|
||||
// also keeps this safe against #3889 — growing an arm ordered by unseeded
|
||||
// random() is fine, shrinking one is what breaks same-day determinism.
|
||||
func libraryScale(libraryTracks int64) float64 {
|
||||
if libraryTracks <= libraryScaleReference {
|
||||
return 1.0
|
||||
}
|
||||
f := math.Sqrt(float64(libraryTracks) / float64(libraryScaleReference))
|
||||
return math.Min(f, maxLibraryScale)
|
||||
}
|
||||
|
||||
// ScaleForLibrary grows the library-bounded arms of a limit set for a library
|
||||
// of the given size, and leaves the rest alone.
|
||||
//
|
||||
// THE SCALING IS NOT UNIFORM, and that is the substance of it rather than a
|
||||
// refinement. An arm's limit only matters if there are rows for it to cut off,
|
||||
// so what an arm is BOUNDED BY decides whether library size can help it:
|
||||
//
|
||||
// scaled LBSimilar, SimilarArtist, TagOverlap — bounded by similarity
|
||||
// and tag data, which grows as the library does. A bigger library
|
||||
// means more of ListenBrainz's returned MBIDs survive the
|
||||
// local-library filter in similarity/worker.go.
|
||||
// scaled RandomFill — the exploration arm, and the one the complaint is
|
||||
// really about. This is where "fraction of the library" lives.
|
||||
// unscaled LikesOverlap — bounded by the USER's likes.
|
||||
// unscaled UserCoplay — bounded by the INSTANCE's co-play graph.
|
||||
// unscaled TasteOverlap — bounded by the taste profile's artists.
|
||||
//
|
||||
// Raising the last three would not sample more of the library; it would
|
||||
// sample more of a set that did not grow, which is churn rather than reach.
|
||||
// Leaving TasteOverlap alone has a second benefit: it and RandomFill are the
|
||||
// two arms carrying sim_score 0, so this does not inflate the
|
||||
// seed-independent share as fast as a uniform scale would.
|
||||
func ScaleForLibrary(base CandidateSourceLimits, libraryTracks int64) CandidateSourceLimits {
|
||||
f := libraryScale(libraryTracks)
|
||||
grow := func(n int) int { return int(float64(n) * f) } // f >= 1, so never shrinks
|
||||
return CandidateSourceLimits{
|
||||
LBSimilar: grow(base.LBSimilar),
|
||||
SimilarArtist: grow(base.SimilarArtist),
|
||||
TagOverlap: grow(base.TagOverlap),
|
||||
RandomFill: grow(base.RandomFill),
|
||||
LikesOverlap: base.LikesOverlap,
|
||||
UserCoplay: base.UserCoplay,
|
||||
TasteOverlap: base.TasteOverlap,
|
||||
}
|
||||
}
|
||||
|
||||
// CountLibraryTracks returns the whole library's track count.
|
||||
//
|
||||
// Reuses CountTracksMatching with an empty pattern — `title ILIKE '%%'`
|
||||
// matches every row — rather than adding a query, because a new one would
|
||||
// need sqlc regeneration (rule 28, and see #3889 for where that blocks).
|
||||
// The user id is left invalid so the quarantine anti-join is skipped: this
|
||||
// number sizes a pool, and a per-user view of it would be false precision.
|
||||
func CountLibraryTracks(ctx context.Context, q *dbq.Queries) (int64, error) {
|
||||
return q.CountTracksMatching(ctx, dbq.CountTracksMatchingParams{
|
||||
Column1: "",
|
||||
UserID: pgtype.UUID{}, // invalid → NULL → no quarantine filter
|
||||
})
|
||||
}
|
||||
|
||||
// librarySizeTTL is how long a counted size is reused. Library size changes
|
||||
// only when a scan runs, so minutes are plenty — and the count is a full
|
||||
// table scan (the ILIKE defeats every index), which is why it is not done
|
||||
// per request.
|
||||
const librarySizeTTL = 5 * time.Minute
|
||||
|
||||
// librarySizeTimeout bounds the count itself — see loadWithDeadline.
|
||||
const librarySizeTimeout = 3 * time.Second
|
||||
|
||||
// LibrarySize memoises the library track count.
|
||||
//
|
||||
// Degrades rather than fails: a count that errors or times out leaves the
|
||||
// previous value in place, and a zero (never yet counted) scales to the base
|
||||
// limits — which is exactly today's behaviour. Nothing about sizing a
|
||||
// candidate pool justifies failing the request it is sizing.
|
||||
type LibrarySize struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time // injectable for tests
|
||||
at time.Time
|
||||
size int64
|
||||
}
|
||||
|
||||
// NewLibrarySize returns an empty cache. The zero value works too; this
|
||||
// exists so tests can pin the clock.
|
||||
func NewLibrarySize(now func() time.Time) *LibrarySize {
|
||||
return &LibrarySize{now: now}
|
||||
}
|
||||
|
||||
// Get returns the cached size, refreshing through load when stale.
|
||||
//
|
||||
// A NIL RECEIVER IS VALID and means "no cache": the count still runs, it is
|
||||
// just not memoised. That is deliberate rather than defensive habit. The api
|
||||
// handlers struct is built directly by a dozen tests that cannot know about
|
||||
// every field, and a nil here previously panicked inside a radio request —
|
||||
// turning a missing pool-sizing HINT into a 500. Uncached-but-correct is the
|
||||
// right failure for something whose whole contract is that it degrades.
|
||||
func (l *LibrarySize) Get(ctx context.Context, load func(context.Context) (int64, error)) int64 {
|
||||
if l == nil {
|
||||
n, err := loadWithDeadline(ctx, load)
|
||||
if err != nil {
|
||||
return 0 // scales to the base limits
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
now := time.Now
|
||||
if l.now != nil {
|
||||
now = l.now
|
||||
}
|
||||
if !l.at.IsZero() && now().Sub(l.at) < librarySizeTTL {
|
||||
return l.size
|
||||
}
|
||||
|
||||
n, err := loadWithDeadline(ctx, load)
|
||||
if err != nil {
|
||||
// Keep the last known value and re-try at the next call rather than
|
||||
// stamping `at`, so a transient failure does not pin a stale number
|
||||
// for the whole TTL.
|
||||
return l.size
|
||||
}
|
||||
l.size, l.at = n, now()
|
||||
return l.size
|
||||
}
|
||||
|
||||
// loadWithDeadline bounds the count. Rule 156: the caller is a user waiting
|
||||
// on a radio, and a pool-sizing hint is never worth hanging for.
|
||||
func loadWithDeadline(ctx context.Context, load func(context.Context) (int64, error)) (int64, error) {
|
||||
cctx, cancel := context.WithTimeout(ctx, librarySizeTimeout)
|
||||
defer cancel()
|
||||
return load(cctx)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Small libraries must behave exactly as before. Scaling is meant to help a
|
||||
// growing collection, not to change what every existing install already does.
|
||||
func TestScaleForLibrary_SmallLibrariesAreUntouched(t *testing.T) {
|
||||
base := DefaultCandidateSourceLimits()
|
||||
for _, size := range []int64{0, 1, 500, libraryScaleReference} {
|
||||
if got := ScaleForLibrary(base, size); got != base {
|
||||
t.Errorf("library of %d changed the limits: %+v, want %+v", size, got, base)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The complaint, restated as a property: a bigger library must reach further
|
||||
// into itself. Operator, 2026-09-10 — "is the pool that we draw from somehow
|
||||
// scaled to the amount of music in the library".
|
||||
func TestScaleForLibrary_BigLibrariesGetABiggerPool(t *testing.T) {
|
||||
base := DefaultCandidateSourceLimits()
|
||||
small := ScaleForLibrary(base, libraryScaleReference)
|
||||
big := ScaleForLibrary(base, libraryScaleReference*16)
|
||||
|
||||
if big.RandomFill <= small.RandomFill {
|
||||
t.Errorf("RandomFill %d did not grow for a 16x library (was %d); that arm "+
|
||||
"IS the fraction-of-library problem", big.RandomFill, small.RandomFill)
|
||||
}
|
||||
if big.LBSimilar <= small.LBSimilar {
|
||||
t.Errorf("LBSimilar %d did not grow for a 16x library (was %d)",
|
||||
big.LBSimilar, small.LBSimilar)
|
||||
}
|
||||
}
|
||||
|
||||
// THE SUBSTANCE: scaling is per-arm, decided by what each arm is BOUNDED BY.
|
||||
// Raising a limit only helps if there are rows for it to cut off, so arms
|
||||
// bounded by the user's likes, the instance's co-play graph or the taste
|
||||
// profile gain nothing from a bigger library — raising them would sample more
|
||||
// of a set that did not grow.
|
||||
//
|
||||
// A uniform scale would look right and be wrong, which is why this is pinned
|
||||
// separately from "the pool got bigger".
|
||||
func TestScaleForLibrary_OnlyLibraryBoundedArmsGrow(t *testing.T) {
|
||||
base := DefaultCandidateSourceLimits()
|
||||
big := ScaleForLibrary(base, libraryScaleReference*16)
|
||||
|
||||
for _, tc := range []struct {
|
||||
arm string
|
||||
got, want int
|
||||
why string
|
||||
}{
|
||||
{"LikesOverlap", big.LikesOverlap, base.LikesOverlap, "bounded by the user's likes"},
|
||||
{"UserCoplay", big.UserCoplay, base.UserCoplay, "bounded by the instance's co-play graph"},
|
||||
{"TasteOverlap", big.TasteOverlap, base.TasteOverlap, "bounded by the taste profile's artists"},
|
||||
} {
|
||||
if tc.got != tc.want {
|
||||
t.Errorf("%s scaled to %d (base %d) but is %s — a bigger library gives it "+
|
||||
"nothing more to return", tc.arm, tc.got, tc.want, tc.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Growth is bounded, or a huge library turns every recommendation query into
|
||||
// a huge scan. sqrt keeps it proportional; the ceiling keeps it affordable.
|
||||
func TestScaleForLibrary_GrowthIsBounded(t *testing.T) {
|
||||
base := DefaultCandidateSourceLimits()
|
||||
huge := ScaleForLibrary(base, 100_000_000)
|
||||
|
||||
if huge.RandomFill > int(float64(base.RandomFill)*maxLibraryScale) {
|
||||
t.Errorf("RandomFill %d exceeds the %.0fx ceiling on base %d",
|
||||
huge.RandomFill, maxLibraryScale, base.RandomFill)
|
||||
}
|
||||
// sqrt, not linear — and the test point matters. At SIXTEEN times the
|
||||
// reference both curves land on 4x, because linear has already been
|
||||
// clamped by the ceiling; asserting there proves nothing. Four times the
|
||||
// reference is below the ceiling for both, so the curves separate: sqrt
|
||||
// gives 2x, linear would give 4x.
|
||||
four := ScaleForLibrary(base, libraryScaleReference*4)
|
||||
if four.RandomFill != base.RandomFill*2 {
|
||||
t.Errorf("4x library gave RandomFill %d, want %d — sqrt growth (linear "+
|
||||
"would give %d)", four.RandomFill, base.RandomFill*2, base.RandomFill*4)
|
||||
}
|
||||
}
|
||||
|
||||
// Never below the base. The base limits are a floor, not a midpoint — and
|
||||
// #3889 makes this load-bearing rather than tidy: shrinking an arm ordered by
|
||||
// unseeded random() changes pool membership between same-day rebuilds.
|
||||
func TestScaleForLibrary_NeverShrinksAnArm(t *testing.T) {
|
||||
base := DefaultCandidateSourceLimits()
|
||||
for _, size := range []int64{0, 1, 100, 4999, 5001, 1_000_000} {
|
||||
got := ScaleForLibrary(base, size)
|
||||
for _, tc := range []struct {
|
||||
arm string
|
||||
got, want int
|
||||
}{
|
||||
{"LBSimilar", got.LBSimilar, base.LBSimilar},
|
||||
{"SimilarArtist", got.SimilarArtist, base.SimilarArtist},
|
||||
{"TagOverlap", got.TagOverlap, base.TagOverlap},
|
||||
{"RandomFill", got.RandomFill, base.RandomFill},
|
||||
{"LikesOverlap", got.LikesOverlap, base.LikesOverlap},
|
||||
{"UserCoplay", got.UserCoplay, base.UserCoplay},
|
||||
{"TasteOverlap", got.TasteOverlap, base.TasteOverlap},
|
||||
} {
|
||||
if tc.got < tc.want {
|
||||
t.Errorf("library %d shrank %s to %d (base %d)", size, tc.arm, tc.got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A pool-sizing hint must never fail the request it is sizing. A count that
|
||||
// errors leaves the previous value in place, and a never-counted cache
|
||||
// returns 0 — which scales to the base limits, i.e. exactly today's
|
||||
// behaviour.
|
||||
func TestLibrarySize_DegradesOnFailure(t *testing.T) {
|
||||
clock := time.Now()
|
||||
c := NewLibrarySize(func() time.Time { return clock })
|
||||
|
||||
boom := func(context.Context) (int64, error) { return 0, errors.New("db is down") }
|
||||
if got := c.Get(context.Background(), boom); got != 0 {
|
||||
t.Errorf("first failure returned %d, want 0 (which scales to the base limits)", got)
|
||||
}
|
||||
if got := ScaleForLibrary(DefaultCandidateSourceLimits(), 0); got != DefaultCandidateSourceLimits() {
|
||||
t.Error("a zero library size did not scale to the base limits")
|
||||
}
|
||||
|
||||
// A good count, then a failure: the last known value survives.
|
||||
if got := c.Get(context.Background(), func(context.Context) (int64, error) { return 40_000, nil }); got != 40_000 {
|
||||
t.Fatalf("got %d, want 40000", got)
|
||||
}
|
||||
clock = clock.Add(librarySizeTTL + time.Second)
|
||||
if got := c.Get(context.Background(), boom); got != 40_000 {
|
||||
t.Errorf("a failed refresh returned %d, discarding the last known 40000", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The count is a full table scan, so it must not run per request.
|
||||
func TestLibrarySize_CachesWithinTheTTL(t *testing.T) {
|
||||
clock := time.Now()
|
||||
c := NewLibrarySize(func() time.Time { return clock })
|
||||
calls := 0
|
||||
load := func(context.Context) (int64, error) { calls++; return 1234, nil }
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
c.Get(context.Background(), load)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("counted %d times within the TTL, want 1 — this is a full table scan", calls)
|
||||
}
|
||||
|
||||
clock = clock.Add(librarySizeTTL + time.Second)
|
||||
c.Get(context.Background(), load)
|
||||
if calls != 2 {
|
||||
t.Errorf("counted %d times after the TTL expired, want 2 — the size never refreshes", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// A transient failure must not pin a stale value for the whole TTL: the
|
||||
// failed refresh does not stamp the clock, so the next call retries.
|
||||
func TestLibrarySize_RetriesAfterAFailedRefresh(t *testing.T) {
|
||||
clock := time.Now()
|
||||
c := NewLibrarySize(func() time.Time { return clock })
|
||||
|
||||
c.Get(context.Background(), func(context.Context) (int64, error) { return 100, nil })
|
||||
clock = clock.Add(librarySizeTTL + time.Second)
|
||||
c.Get(context.Background(), func(context.Context) (int64, error) { return 0, errors.New("blip") })
|
||||
|
||||
// Immediately after, with no clock movement — a stamped failure would
|
||||
// serve the stale 100 until the TTL expired again.
|
||||
if got := c.Get(context.Background(), func(context.Context) (int64, error) { return 900, nil }); got != 900 {
|
||||
t.Errorf("got %d after a failed refresh, want 900 — the failure pinned a stale value", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A nil cache must not panic, and must still be CORRECT — uncached, not
|
||||
// broken.
|
||||
//
|
||||
// This is not a hypothetical hardening. internal/api builds its handlers
|
||||
// struct directly in a dozen tests, none of which know about every field, so
|
||||
// librarySize arrives nil there. The first version of this panicked inside
|
||||
// handleRadio and took down TestHandleRadio_ColdStart_OnlySeedReturned with a
|
||||
// SIGSEGV — turning a missing pool-sizing HINT into a request-killing crash,
|
||||
// which is the opposite of what a value that "degrades rather than fails" is
|
||||
// supposed to do.
|
||||
func TestLibrarySize_NilReceiverStillCounts(t *testing.T) {
|
||||
var c *LibrarySize // deliberately not constructed
|
||||
|
||||
calls := 0
|
||||
got := c.Get(context.Background(), func(context.Context) (int64, error) {
|
||||
calls++
|
||||
return 40_000, nil
|
||||
})
|
||||
if got != 40_000 {
|
||||
t.Errorf("nil cache returned %d, want 40000 — it should still count, just not memoise", got)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("nil cache called the loader %d times, want 1", calls)
|
||||
}
|
||||
|
||||
// Uncached: a second call counts again rather than reusing anything.
|
||||
c.Get(context.Background(), func(context.Context) (int64, error) { calls++; return 40_000, nil })
|
||||
if calls != 2 {
|
||||
t.Errorf("nil cache memoised across calls (%d loads); it has nowhere to store a value", calls)
|
||||
}
|
||||
|
||||
// And it still degrades on error rather than panicking.
|
||||
if got := c.Get(context.Background(), func(context.Context) (int64, error) {
|
||||
return 0, errors.New("db is down")
|
||||
}); got != 0 {
|
||||
t.Errorf("nil cache returned %d on a failed count, want 0 (base limits)", got)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
@@ -13,15 +15,72 @@ type Candidate struct {
|
||||
Inputs ScoringInputs
|
||||
}
|
||||
|
||||
// Shuffle scores each candidate, sorts descending by score, and returns
|
||||
// the top `limit` candidates. limit <= 0 returns nil; nil input returns
|
||||
// nil. Pure — no IO, no global state beyond the rng callback.
|
||||
// DiversityCaps bounds how much of one selection a single artist or album
|
||||
// may occupy. A zero value means "no cap", which is what Shuffle did
|
||||
// unconditionally before #3882.
|
||||
type DiversityCaps struct {
|
||||
MaxPerArtist int
|
||||
MaxPerAlbum int
|
||||
}
|
||||
|
||||
// radioCapArtistPer25 / radioCapAlbumPer25 hold the caps at the same
|
||||
// PROPORTION the system mixes already use — 3 artist / 2 album tracks in a
|
||||
// 25-track mix — so a 20-track radio and a 200-track one feel alike rather
|
||||
// than one of them being effectively uncapped.
|
||||
//
|
||||
// A fixed count cannot do that. Three-per-artist is a reasonable 12% of a
|
||||
// 25-track mix and an absurd 1.5% of a 200-track radio, where it would put
|
||||
// the selection permanently in the relaxation path below and quietly undo
|
||||
// the cap it was meant to enforce.
|
||||
const (
|
||||
radioCapArtistPer25 = 3
|
||||
radioCapAlbumPer25 = 2
|
||||
)
|
||||
|
||||
// RadioDiversityCaps scales the diversity caps to the requested radio
|
||||
// length. Floors of 2 and 1 keep a very short radio from being capped into
|
||||
// a single track per artist, which would be its own kind of wrong.
|
||||
func RadioDiversityCaps(limit int) DiversityCaps {
|
||||
artist := limit * radioCapArtistPer25 / 25
|
||||
if artist < 2 {
|
||||
artist = 2
|
||||
}
|
||||
album := limit * radioCapAlbumPer25 / 25
|
||||
if album < 1 {
|
||||
album = 1
|
||||
}
|
||||
return DiversityCaps{MaxPerArtist: artist, MaxPerAlbum: album}
|
||||
}
|
||||
|
||||
// Shuffle scores each candidate, sorts descending by score, and returns the
|
||||
// top `limit` candidates, preferring artist/album diversity. limit <= 0
|
||||
// returns nil; nil input returns nil. Pure — no IO, no global state beyond
|
||||
// the rng callback.
|
||||
//
|
||||
// THE COUNT IS NEVER REDUCED BY THE CAPS. Selection runs in two passes: the
|
||||
// first takes candidates that fit under the caps, and the second fills any
|
||||
// remaining slots from those the first pass skipped, still in score order.
|
||||
// So the result holds min(limit, len(candidates)) either way — the caps
|
||||
// change WHICH tracks are chosen, never HOW MANY.
|
||||
//
|
||||
// That two-pass shape is the whole design, and a hard cap would have been
|
||||
// the easy mistake. Radio asks for 50 tracks by default and 200 at most; a
|
||||
// pool concentrated on a few artists would return six tracks and call it a
|
||||
// radio. Rule 131's principle — degrade, never vanish — applies past the
|
||||
// system mixes it was written for.
|
||||
//
|
||||
// Before #3882 there was no cap here at all, while discover.go,
|
||||
// you_might_like.go and home.go all had one. That asymmetry is what let a
|
||||
// radio session come back entirely from a single artist: nothing between
|
||||
// the pool and the output bounded any artist's share, so a pool dominated
|
||||
// by one artist produced an output dominated by it too.
|
||||
func Shuffle(
|
||||
candidates []Candidate,
|
||||
weights ScoringWeights,
|
||||
now time.Time,
|
||||
rng func() float64,
|
||||
limit int,
|
||||
caps DiversityCaps,
|
||||
) []Candidate {
|
||||
if len(candidates) == 0 || limit <= 0 {
|
||||
return nil
|
||||
@@ -40,9 +99,36 @@ func Shuffle(
|
||||
if limit > len(scored) {
|
||||
limit = len(scored)
|
||||
}
|
||||
out := make([]Candidate, limit)
|
||||
for i := 0; i < limit; i++ {
|
||||
out[i] = scored[i].c
|
||||
|
||||
out := make([]Candidate, 0, limit)
|
||||
deferred := make([]Candidate, 0, len(scored)-limit)
|
||||
artistCount := map[pgtype.UUID]int{}
|
||||
albumCount := map[pgtype.UUID]int{}
|
||||
|
||||
for _, s := range scored {
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
overArtist := caps.MaxPerArtist > 0 && artistCount[s.c.Track.ArtistID] >= caps.MaxPerArtist
|
||||
overAlbum := caps.MaxPerAlbum > 0 && albumCount[s.c.Track.AlbumID] >= caps.MaxPerAlbum
|
||||
if overArtist || overAlbum {
|
||||
// Held back, not discarded — pass two may still need it.
|
||||
deferred = append(deferred, s.c)
|
||||
continue
|
||||
}
|
||||
artistCount[s.c.Track.ArtistID]++
|
||||
albumCount[s.c.Track.AlbumID]++
|
||||
out = append(out, s.c)
|
||||
}
|
||||
|
||||
// Pass two: the caps could not fill the request, so relax them rather
|
||||
// than hand back a short radio. Still score order, so the best of the
|
||||
// held-back candidates go first.
|
||||
for _, c := range deferred {
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// candBy builds a candidate with a real artist and album identity, which
|
||||
// `cand` deliberately leaves zero.
|
||||
func candBy(t *testing.T, id, artist, album string, in ScoringInputs) Candidate {
|
||||
t.Helper()
|
||||
tr := dbq.Track{Title: id}
|
||||
if err := tr.ID.Scan("00000000-0000-0000-0000-" + id); err != nil {
|
||||
t.Fatalf("track id %q: %v", id, err)
|
||||
}
|
||||
if err := tr.ArtistID.Scan("00000000-0000-0000-0001-" + artist); err != nil {
|
||||
t.Fatalf("artist id %q: %v", artist, err)
|
||||
}
|
||||
if err := tr.AlbumID.Scan("00000000-0000-0000-0002-" + album); err != nil {
|
||||
t.Fatalf("album id %q: %v", album, err)
|
||||
}
|
||||
return Candidate{Track: tr, Inputs: in}
|
||||
}
|
||||
|
||||
// artistKey is the map key artistsOf produces for a given fixture artist,
|
||||
// derived the same way the fixture builds the UUID. Hand-writing the hex is
|
||||
// how the first version of this test went wrong: the artist id is not all
|
||||
// zeros — it carries 0001 in its fourth group — so the literal did not match
|
||||
// and the assertion measured nothing.
|
||||
func artistKey(t *testing.T, artist string) string {
|
||||
t.Helper()
|
||||
var id pgtype.UUID
|
||||
if err := id.Scan("00000000-0000-0000-0001-" + artist); err != nil {
|
||||
t.Fatalf("artist id %q: %v", artist, err)
|
||||
}
|
||||
return fmt.Sprintf("%x", id.Bytes)
|
||||
}
|
||||
|
||||
// artistsOf counts how many picks each artist contributed.
|
||||
func artistsOf(picks []Candidate) map[string]int {
|
||||
out := map[string]int{}
|
||||
for _, p := range picks {
|
||||
out[fmt.Sprintf("%x", p.Track.ArtistID.Bytes)]++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// THE BUG. Operator, 2026-09-10: started radio from a song and "literally
|
||||
// all of the songs in the playlist after that were from a single artist".
|
||||
//
|
||||
// One artist's tracks all outscore everything else, and there are more of
|
||||
// them than the radio has room for. Without a cap the output is entirely
|
||||
// that artist — nothing between the pool and the result bounded its share.
|
||||
func TestShuffle_CapStopsOneArtistTakingTheWholeRadio(t *testing.T) {
|
||||
var cs []Candidate
|
||||
// 20 tracks by artist A, all liked so they sort to the top.
|
||||
for i := 0; i < 20; i++ {
|
||||
cs = append(cs, candBy(t, fmt.Sprintf("%012d", i), "00000000000a",
|
||||
fmt.Sprintf("%012d", i), ScoringInputs{IsGeneralLiked: true}))
|
||||
}
|
||||
// 10 tracks by 10 other artists, none liked, so they all rank below.
|
||||
for i := 0; i < 10; i++ {
|
||||
cs = append(cs, candBy(t, fmt.Sprintf("%012d", 100+i),
|
||||
fmt.Sprintf("%012d", 200+i), fmt.Sprintf("%012d", 100+i),
|
||||
ScoringInputs{IsGeneralLiked: false}))
|
||||
}
|
||||
|
||||
const limit = 10
|
||||
caps := DiversityCaps{MaxPerArtist: 3}
|
||||
picks := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), limit, caps)
|
||||
|
||||
if len(picks) != limit {
|
||||
t.Fatalf("len = %d, want %d", len(picks), limit)
|
||||
}
|
||||
byArtist := artistsOf(picks)
|
||||
artistA := artistKey(t, "00000000000a")
|
||||
if got := byArtist[artistA]; got != 3 {
|
||||
t.Errorf("artist A contributed %d of %d picks, cap is 3", got, limit)
|
||||
}
|
||||
if len(byArtist) < 8 {
|
||||
t.Errorf("only %d distinct artists in a %d-track radio; the cap is not "+
|
||||
"spreading the selection", len(byArtist), limit)
|
||||
}
|
||||
|
||||
// The same pool with NO cap is the regression this exists to catch. If
|
||||
// this stops holding, the test above is no longer proving anything.
|
||||
uncapped := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), limit, DiversityCaps{})
|
||||
if artistsOf(uncapped)[artistA] != limit {
|
||||
t.Errorf("uncapped selection was not single-artist, so this fixture no " +
|
||||
"longer reproduces the bug being fixed")
|
||||
}
|
||||
}
|
||||
|
||||
// A CAP IS A BOUND, NOT AN EXCLUSION. The operator asked for the opposite of
|
||||
// removal: "again it should be able to add songs from the same artist."
|
||||
func TestShuffle_CappedArtistIsStillRepresented(t *testing.T) {
|
||||
var cs []Candidate
|
||||
for i := 0; i < 20; i++ {
|
||||
cs = append(cs, candBy(t, fmt.Sprintf("%012d", i), "00000000000a",
|
||||
fmt.Sprintf("%012d", i), ScoringInputs{IsGeneralLiked: true}))
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
cs = append(cs, candBy(t, fmt.Sprintf("%012d", 100+i),
|
||||
fmt.Sprintf("%012d", 200+i), fmt.Sprintf("%012d", 100+i),
|
||||
ScoringInputs{IsGeneralLiked: false}))
|
||||
}
|
||||
picks := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10,
|
||||
DiversityCaps{MaxPerArtist: 3})
|
||||
if artistsOf(picks)[artistKey(t, "00000000000a")] == 0 {
|
||||
t.Error("the dominant artist was excluded entirely; the cap should bound " +
|
||||
"its share, not remove it")
|
||||
}
|
||||
}
|
||||
|
||||
// RULE 131, applied past the system mixes it was written for: degrade, never
|
||||
// vanish. A hard cap over a pool with few artists would hand back a six-track
|
||||
// "radio" for a fifty-track request. The caps must change WHICH tracks are
|
||||
// picked, never HOW MANY.
|
||||
func TestShuffle_CapsNeverShortenTheResult(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
artists int
|
||||
perArt int
|
||||
limit int
|
||||
}{
|
||||
{"one artist owns the entire pool", 1, 30, 10},
|
||||
{"two artists, tight cap", 2, 15, 20},
|
||||
{"pool smaller than the request", 3, 2, 50},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var cs []Candidate
|
||||
n := 0
|
||||
for a := 0; a < tc.artists; a++ {
|
||||
for k := 0; k < tc.perArt; k++ {
|
||||
cs = append(cs, candBy(t, fmt.Sprintf("%012d", n),
|
||||
fmt.Sprintf("%012d", 300+a), fmt.Sprintf("%012d", n),
|
||||
ScoringInputs{}))
|
||||
n++
|
||||
}
|
||||
}
|
||||
want := tc.limit
|
||||
if len(cs) < want {
|
||||
want = len(cs)
|
||||
}
|
||||
picks := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), tc.limit,
|
||||
DiversityCaps{MaxPerArtist: 3, MaxPerAlbum: 2})
|
||||
if len(picks) != want {
|
||||
t.Errorf("len = %d, want %d — the caps shortened the result instead "+
|
||||
"of relaxing to fill it", len(picks), want)
|
||||
}
|
||||
// No duplicates: a candidate deferred in pass one must not also be
|
||||
// taken in pass two.
|
||||
seen := map[[16]byte]bool{}
|
||||
for _, p := range picks {
|
||||
if seen[p.Track.ID.Bytes] {
|
||||
t.Fatalf("track %x appears twice; pass two re-added a pick", p.Track.ID.Bytes)
|
||||
}
|
||||
seen[p.Track.ID.Bytes] = true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A fixed count cannot serve both a 20-track radio and a 200-track one: three
|
||||
// per artist is 12% of the first and 1.5% of the second, which would leave the
|
||||
// long radio permanently in the relaxation path and effectively uncapped.
|
||||
func TestRadioDiversityCaps_ScaleWithTheRequestedLength(t *testing.T) {
|
||||
short := RadioDiversityCaps(25)
|
||||
long := RadioDiversityCaps(200)
|
||||
|
||||
if short.MaxPerArtist != 3 {
|
||||
t.Errorf("a 25-track radio caps artists at %d, want 3 — the same "+
|
||||
"proportion the system mixes use", short.MaxPerArtist)
|
||||
}
|
||||
if long.MaxPerArtist <= short.MaxPerArtist {
|
||||
t.Errorf("a 200-track radio caps artists at %d, no higher than a 25-track "+
|
||||
"one at %d; the cap is not scaling", long.MaxPerArtist, short.MaxPerArtist)
|
||||
}
|
||||
// Proportion held, not just "bigger".
|
||||
if long.MaxPerArtist != 24 {
|
||||
t.Errorf("200-track artist cap = %d, want 24 (3 per 25)", long.MaxPerArtist)
|
||||
}
|
||||
|
||||
// Floors: a tiny radio must not be capped down to one track per artist.
|
||||
tiny := RadioDiversityCaps(1)
|
||||
if tiny.MaxPerArtist < 2 {
|
||||
t.Errorf("tiny radio artist cap = %d, want at least 2", tiny.MaxPerArtist)
|
||||
}
|
||||
if tiny.MaxPerAlbum < 1 {
|
||||
t.Errorf("tiny radio album cap = %d, want at least 1", tiny.MaxPerAlbum)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ func TestShuffle_LikedRanksAboveUnliked(t *testing.T) {
|
||||
cand("000000000001", ScoringInputs{IsGeneralLiked: false}),
|
||||
cand("000000000002", ScoringInputs{IsGeneralLiked: true}),
|
||||
}
|
||||
out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10)
|
||||
out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10, DiversityCaps{})
|
||||
if out[0].Track.Title != "000000000002" {
|
||||
t.Errorf("liked track did not rank first: %+v", out)
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func TestShuffle_HighSkipRanksLast(t *testing.T) {
|
||||
cand("000000000002", ScoringInputs{PlayCount: 10, SkipCount: 0}), // ratio 0
|
||||
cand("000000000003", ScoringInputs{PlayCount: 10, SkipCount: 5}), // ratio 0.5
|
||||
}
|
||||
out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10)
|
||||
out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10, DiversityCaps{})
|
||||
if out[0].Track.Title != "000000000002" || out[2].Track.Title != "000000000001" {
|
||||
t.Errorf("skip-ratio ordering broken: %v", titles(out))
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func TestShuffle_LimitTruncates(t *testing.T) {
|
||||
for i := range cs {
|
||||
cs[i] = cand("00000000000"+string(rune('a'+i%26)), ScoringInputs{})
|
||||
}
|
||||
out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10)
|
||||
out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10, DiversityCaps{})
|
||||
if len(out) != 10 {
|
||||
t.Errorf("len = %d, want 10", len(out))
|
||||
}
|
||||
@@ -58,7 +58,7 @@ func TestShuffle_JitterDoesNotFlipStructuralWinner(t *testing.T) {
|
||||
cand("000000000001", ScoringInputs{IsGeneralLiked: false}),
|
||||
cand("000000000002", ScoringInputs{IsGeneralLiked: true}),
|
||||
}
|
||||
out := Shuffle(cs, defaultWeights(), time.Now(), r.Float64, 10)
|
||||
out := Shuffle(cs, defaultWeights(), time.Now(), r.Float64, 10, DiversityCaps{})
|
||||
if out[0].Track.Title != "000000000002" {
|
||||
t.Fatalf("iter %d: liked did not rank first; out=%v", i, titles(out))
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func TestShuffle_JitterDoesNotFlipStructuralWinner(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestShuffle_Empty_ReturnsEmpty(t *testing.T) {
|
||||
out := Shuffle(nil, defaultWeights(), time.Now(), fixedRNG(0.5), 10)
|
||||
out := Shuffle(nil, defaultWeights(), time.Now(), fixedRNG(0.5), 10, DiversityCaps{})
|
||||
if len(out) != 0 {
|
||||
t.Errorf("len = %d, want 0", len(out))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package recommendation
|
||||
|
||||
import "testing"
|
||||
|
||||
// Songs-like's pool must lean on arms that MEASURE distance from the seed.
|
||||
// The default gave ~29% of candidates a sim_score of literally 0
|
||||
// (taste_overlap and random_fill are both `0.0::float8` in
|
||||
// recommendation.sql), which is what let "Songs like X" wander.
|
||||
func TestSongsLikeLimits_FavourTheArmsThatMeasureTheSeed(t *testing.T) {
|
||||
d := DefaultCandidateSourceLimits()
|
||||
s := SongsLikeCandidateSourceLimits()
|
||||
|
||||
if s.LBSimilar <= d.LBSimilar {
|
||||
t.Errorf("LBSimilar %d is not above the default %d — the only arm that "+
|
||||
"measures track-level distance from the seed should be favoured here",
|
||||
s.LBSimilar, d.LBSimilar)
|
||||
}
|
||||
// The two seed-INDEPENDENT arms, which is the whole complaint.
|
||||
zeroSimDefault := d.TasteOverlap + d.RandomFill
|
||||
zeroSimSongsLike := s.TasteOverlap + s.RandomFill
|
||||
if zeroSimSongsLike >= zeroSimDefault {
|
||||
t.Errorf("seed-independent arms total %d, not reduced from the default %d; "+
|
||||
"these carry sim_score 0 by construction", zeroSimSongsLike, zeroSimDefault)
|
||||
}
|
||||
}
|
||||
|
||||
// RULE 131: a system playlist degrades, it never vanishes.
|
||||
//
|
||||
// Zeroing the seed-independent arms was the first instinct and is exactly the
|
||||
// vanish-or-nothing shape that rule forbids: a seed whose artist has thin
|
||||
// ListenBrainz coverage would yield a short mix or none at all. They are the
|
||||
// tier-3 FLOOR — reduced hard, never removed — and the songs_like weights are
|
||||
// what keep them at the bottom of the ranking rather than out of the pool.
|
||||
//
|
||||
// "A few tracks further from the seed than we would like" beats "no playlist".
|
||||
func TestSongsLikeLimits_KeepATierThreeFloor(t *testing.T) {
|
||||
s := SongsLikeCandidateSourceLimits()
|
||||
if s.RandomFill <= 0 {
|
||||
t.Error("RandomFill is zero: a seed with thin similarity coverage now produces " +
|
||||
"a short or empty mix instead of degrading (rule 131)")
|
||||
}
|
||||
if s.TasteOverlap <= 0 {
|
||||
t.Error("TasteOverlap is zero: the graded floor is gone, leaving only random " +
|
||||
"fill between a sparse seed and an empty playlist (rule 131)")
|
||||
}
|
||||
}
|
||||
|
||||
// The pool should stay roughly the size it was — this change is about
|
||||
// COMPOSITION, not about starving the surface. A much smaller pool would also
|
||||
// shrink what the per-artist cap has to work with.
|
||||
func TestSongsLikeLimits_KeepThePoolRoughlyTheSameSize(t *testing.T) {
|
||||
total := func(l CandidateSourceLimits) int {
|
||||
return l.LBSimilar + l.SimilarArtist + l.TagOverlap + l.LikesOverlap +
|
||||
l.RandomFill + l.TasteOverlap + l.UserCoplay
|
||||
}
|
||||
d, s := total(DefaultCandidateSourceLimits()), total(SongsLikeCandidateSourceLimits())
|
||||
if s < d/2 {
|
||||
t.Errorf("songs_like pool is %d against the default %d — less than half; "+
|
||||
"this was meant to re-weight the pool, not starve it", s, d)
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,13 @@ const (
|
||||
// never be read as taste signal (#2374) — filing it under taste would put
|
||||
// it one careless join from the leak that design forbids.
|
||||
ScopeDiscover = "discover"
|
||||
// ScopeSongsLike is the "Songs like {X}" surface (#3881). It shared
|
||||
// daily_mix with For-You until 2026-09-10, and that sharing WAS the bug:
|
||||
// the two surfaces want opposite things. For-You answers "what will they
|
||||
// enjoy today" and is supposed to roam; Songs-like answers "what sounds
|
||||
// like THIS" and is the tightest surface in the product. One set of
|
||||
// weights cannot serve both, and the broad answer was winning.
|
||||
ScopeSongsLike = "songs_like"
|
||||
)
|
||||
|
||||
// TasteTuning is the tunable subset of taste.Config: the engagement
|
||||
@@ -92,6 +99,76 @@ func ShippedDailyMixWeights() recommendation.ScoringWeights {
|
||||
}
|
||||
}
|
||||
|
||||
// ShippedSongsLikeWeights are the shipped songs_like-profile defaults
|
||||
// (#3881). The whole point is that SIMILARITY DOMINATES; every other
|
||||
// profile balances it against taste and engagement, and this one must not.
|
||||
//
|
||||
// The failure being corrected, arithmetic from the daily_mix profile that
|
||||
// this surface used to share:
|
||||
//
|
||||
// unrelated track, liked, not played recently → 1.0 + 2.0 + 1.0 = 4.0
|
||||
// PERFECT similarity match, not liked → 1.0 + 1.5 = 2.5
|
||||
//
|
||||
// Liking something outranked sounding like the seed, because LikeBoost (2.0)
|
||||
// exceeded SimilarityWeight's entire range (1.5) and TasteWeight (1.5, and
|
||||
// seed-independent) matched it outright.
|
||||
//
|
||||
// THE PROPERTY THESE NUMBERS ENCODE, which is what to preserve if they are
|
||||
// retuned: the similarity term's range must exceed the combined range of
|
||||
// every seed-INDEPENDENT differentiator, so that a closer match cannot be
|
||||
// beaten on the strength of likes, freshness and taste alone.
|
||||
//
|
||||
// seed-independent spread = LikeBoost 0.5 + Recency 0.25
|
||||
// + Taste 0.25 + ContextTime 0.5
|
||||
// + jitter 0.05 = 1.55
|
||||
// similarity spread = 0 → 4.0
|
||||
//
|
||||
// So a similarity advantage of ~0.39 (1.55/4.0) wins outright regardless of
|
||||
// everything else, while tracks within that band still get ordered by what
|
||||
// the user likes and has not heard lately. Tight, not deaf.
|
||||
//
|
||||
// BaseWeight stays 1.0: it is identical for every candidate and so
|
||||
// differentiates nothing — it sets the floor, not the shape. SkipPenalty
|
||||
// stays 2.0 because a track the user skips is still unwanted no matter how
|
||||
// similar it is.
|
||||
//
|
||||
// These are DEFAULTS, not settings (rule 25) — the operator turns them in the
|
||||
// admin tuning card and good values get baked back here. They are a
|
||||
// defensible starting point rather than a measured optimum: the per-arm fill
|
||||
// rates and the real sim_score distribution are still unknown (#3879), and
|
||||
// `likes_overlap` contributes a flat 0.6 that a high SimilarityWeight
|
||||
// amplifies. Expect to move these once that lands.
|
||||
func ShippedSongsLikeWeights() recommendation.ScoringWeights {
|
||||
return recommendation.ScoringWeights{
|
||||
BaseWeight: 1.0, // same for all candidates; differentiates nothing
|
||||
LikeBoost: 0.5, // was 2.0 — a tie-break among similar tracks, not an override
|
||||
RecencyWeight: 0.25, // was 1.0 — freshness must not outrank sounding right
|
||||
SkipPenalty: 2.0, // unchanged — a skipped track stays unwanted
|
||||
JitterMagnitude: 0.05, // was 0.1 — less shuffle on a coherence surface
|
||||
ContextWeight: 0.5,
|
||||
SimilarityWeight: 4.0, // was 1.5 — dominant, by design
|
||||
TasteWeight: 0.25, // was 1.5 — seed-INDEPENDENT, so demoted hard
|
||||
ContextTimeWeight: 0.5, // was 1.0
|
||||
}
|
||||
}
|
||||
|
||||
// shippedWeightsFor returns the shipped defaults for a weight-profile scope,
|
||||
// or ok=false if the scope is not a weight profile. Single source for the
|
||||
// three call sites (seed, update-validation, reset) so adding a fourth
|
||||
// profile cannot be half-wired — which is how a scope ends up seedable but
|
||||
// not resettable.
|
||||
func shippedWeightsFor(scope string) (recommendation.ScoringWeights, bool) {
|
||||
switch scope {
|
||||
case ScopeRadio:
|
||||
return ShippedRadioWeights(), true
|
||||
case ScopeDailyMix:
|
||||
return ShippedDailyMixWeights(), true
|
||||
case ScopeSongsLike:
|
||||
return ShippedSongsLikeWeights(), true
|
||||
}
|
||||
return recommendation.ScoringWeights{}, false
|
||||
}
|
||||
|
||||
// DiscoverTuning is the tunable set for the Discover request surface (#2377).
|
||||
type DiscoverTuning struct {
|
||||
// TagOverlapWeight scales the taste-tag term: score × (1 + w × overlap).
|
||||
@@ -165,8 +242,9 @@ func New(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*Service
|
||||
func (s *Service) reconcile(ctx context.Context) error {
|
||||
q := dbq.New(s.pool)
|
||||
for profile, w := range map[string]recommendation.ScoringWeights{
|
||||
ScopeRadio: ShippedRadioWeights(),
|
||||
ScopeDailyMix: ShippedDailyMixWeights(),
|
||||
ScopeRadio: ShippedRadioWeights(),
|
||||
ScopeDailyMix: ShippedDailyMixWeights(),
|
||||
ScopeSongsLike: ShippedSongsLikeWeights(),
|
||||
} {
|
||||
if err := q.UpsertWeightProfileDefaults(ctx, upsertParams(profile, w)); err != nil {
|
||||
return fmt.Errorf("seed profile %q: %w", profile, err)
|
||||
@@ -234,6 +312,7 @@ func (s *Service) reconcile(ctx context.Context) error {
|
||||
// reads Weights(ScopeRadio) per request.
|
||||
func (s *Service) push() {
|
||||
playlists.SetSystemMixWeights(s.Weights(ScopeDailyMix))
|
||||
playlists.SetSongsLikeWeights(s.Weights(ScopeSongsLike))
|
||||
playlists.SetTasteConfig(s.TasteConfig())
|
||||
}
|
||||
|
||||
@@ -297,7 +376,7 @@ type fieldChange struct {
|
||||
// Unknown fields and out-of-range values reject the whole patch. A
|
||||
// no-op patch (all values equal to current) writes no audit row.
|
||||
func (s *Service) UpdateProfile(ctx context.Context, profile string, patch map[string]float64) error {
|
||||
if profile != ScopeRadio && profile != ScopeDailyMix {
|
||||
if _, ok := shippedWeightsFor(profile); !ok {
|
||||
return fmt.Errorf("%w: %q", ErrUnknownScope, profile)
|
||||
}
|
||||
current := s.Weights(profile)
|
||||
@@ -340,17 +419,18 @@ func (s *Service) UpdateDiscover(ctx context.Context, patch map[string]float64)
|
||||
// Reset restores a scope to its shipped defaults, with one audit row
|
||||
// carrying the full diff. A scope already at defaults is a no-op.
|
||||
func (s *Service) Reset(ctx context.Context, scope string) error {
|
||||
switch scope {
|
||||
case ScopeRadio, ScopeDailyMix:
|
||||
shipped := ShippedRadioWeights()
|
||||
if scope == ScopeDailyMix {
|
||||
shipped = ShippedDailyMixWeights()
|
||||
}
|
||||
if shipped, ok := shippedWeightsFor(scope); ok {
|
||||
// Every weight profile resets the same way; the per-scope defaults
|
||||
// come from one place so a new profile cannot be seedable but not
|
||||
// resettable. This was an if/else over two hard-coded scopes until
|
||||
// songs_like made it three.
|
||||
changes := diffWeights(s.Weights(scope), shipped)
|
||||
if len(changes) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.persistProfile(ctx, scope, shipped, "reset", changes)
|
||||
}
|
||||
switch scope {
|
||||
case ScopeTaste:
|
||||
shipped := ShippedTasteTuning()
|
||||
changes := diffTaste(s.Taste(), shipped)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package recsettings
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||
)
|
||||
|
||||
// The bug, reproduced as a ranking: a track that sounds nothing like the seed
|
||||
// but which the user liked and has not played lately used to OUTRANK a perfect
|
||||
// similarity match. Operator, 2026-09-10: "I was getting a seeming wide variety
|
||||
// of music from each one when I was hoping to stay in a certain neighborhood."
|
||||
//
|
||||
// This is the whole point of the songs_like profile, so it is asserted as
|
||||
// BEHAVIOUR — two candidates, which one wins — rather than by checking the
|
||||
// weight numbers. Numbers get retuned; this property must survive that.
|
||||
//
|
||||
// It also pins the contrast: daily_mix is EXPECTED to fail this. If both
|
||||
// profiles started ranking the same way, the split would have quietly become
|
||||
// pointless and nothing else would notice.
|
||||
func TestSongsLikeWeights_SimilarityBeatsAnUnrelatedLikedTrack(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
stale := now.Add(-365 * 24 * time.Hour)
|
||||
|
||||
// A perfect similarity match the user has never liked and played recently.
|
||||
// Everything except similarity is working against it.
|
||||
perfectMatch := recommendation.ScoringInputs{
|
||||
SimilarityScore: 1.0,
|
||||
IsGeneralLiked: false,
|
||||
LastPlayedAt: &now,
|
||||
}
|
||||
// Nothing to do with the seed, but liked and long unplayed — every
|
||||
// seed-independent term in its favour.
|
||||
unrelatedFavourite := recommendation.ScoringInputs{
|
||||
SimilarityScore: 0.0,
|
||||
IsGeneralLiked: true,
|
||||
LastPlayedAt: &stale,
|
||||
TasteMatchScore: 1.0,
|
||||
}
|
||||
|
||||
// Jitter fixed at its midpoint so the comparison is about the weights.
|
||||
noJitter := func() float64 { return 0.5 }
|
||||
|
||||
songsLike := ShippedSongsLikeWeights()
|
||||
matchScore := recommendation.Score(perfectMatch, songsLike, now, noJitter)
|
||||
favScore := recommendation.Score(unrelatedFavourite, songsLike, now, noJitter)
|
||||
if matchScore <= favScore {
|
||||
t.Errorf("songs_like ranks an unrelated liked track (%.3f) at or above a "+
|
||||
"perfect similarity match (%.3f) — the mix will wander", favScore, matchScore)
|
||||
}
|
||||
|
||||
// The contrast that makes the split worth having. If this ever passes,
|
||||
// daily_mix has been tightened into songs_like and one of them is redundant.
|
||||
daily := ShippedDailyMixWeights()
|
||||
dMatch := recommendation.Score(perfectMatch, daily, now, noJitter)
|
||||
dFav := recommendation.Score(unrelatedFavourite, daily, now, noJitter)
|
||||
if dMatch > dFav {
|
||||
t.Errorf("daily_mix now also puts similarity first (%.3f vs %.3f); the two "+
|
||||
"profiles no longer differ, so songs_like is buying nothing", dMatch, dFav)
|
||||
}
|
||||
}
|
||||
|
||||
// The property the songs_like numbers encode, stated independently of them:
|
||||
// the similarity term's range must exceed the combined range of every
|
||||
// seed-INDEPENDENT differentiator, so a closer match cannot be beaten on
|
||||
// likes, freshness and taste alone.
|
||||
//
|
||||
// BaseWeight is excluded deliberately — it is identical for every candidate
|
||||
// and so differentiates nothing. SkipPenalty is excluded because it only ever
|
||||
// pushes a candidate DOWN, and a skipped track should lose however similar.
|
||||
func TestSongsLikeWeights_SimilarityOutrangesEverySeedIndependentTerm(t *testing.T) {
|
||||
w := ShippedSongsLikeWeights()
|
||||
|
||||
// TasteMatchScore and ContextAffinityScore are in [-1,+1]; recencyDecay is
|
||||
// in [0,1]; LikeBoost is all-or-nothing.
|
||||
seedIndependent := w.LikeBoost + w.RecencyWeight + w.TasteWeight +
|
||||
w.ContextTimeWeight + w.JitterMagnitude
|
||||
|
||||
if w.SimilarityWeight <= seedIndependent {
|
||||
t.Errorf("SimilarityWeight %.2f does not outrange the seed-independent "+
|
||||
"terms (%.2f) — likes/recency/taste can outvote sounding like the seed",
|
||||
w.SimilarityWeight, seedIndependent)
|
||||
}
|
||||
}
|
||||
|
||||
// A scope that is seedable but not resettable is the half-wired shape this
|
||||
// guards: the profile appears in the admin card, the operator turns a knob,
|
||||
// and Reset then 404s on a scope the rest of the service knows about.
|
||||
func TestShippedWeightsFor_CoversEveryWeightProfile(t *testing.T) {
|
||||
for _, scope := range []string{ScopeRadio, ScopeDailyMix, ScopeSongsLike} {
|
||||
if _, ok := shippedWeightsFor(scope); !ok {
|
||||
t.Errorf("scope %q has no shipped defaults; it cannot be seeded or reset", scope)
|
||||
}
|
||||
}
|
||||
// Non-weight scopes must NOT resolve here, or Reset would treat the taste
|
||||
// singleton as a weight profile and write nonsense.
|
||||
for _, scope := range []string{ScopeTaste, ScopeDiscover, "nonsense"} {
|
||||
if _, ok := shippedWeightsFor(scope); ok {
|
||||
t.Errorf("scope %q resolved as a weight profile and is not one", scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Guards the sync the comment in playlists/system.go asks for: the pre-push
|
||||
// literal there must match the shipped defaults here, or a build that has not
|
||||
// yet been reconciled ranks differently from one that has.
|
||||
func TestShippedSongsLikeWeights_AreDominatedBySimilarity(t *testing.T) {
|
||||
w := ShippedSongsLikeWeights()
|
||||
daily := ShippedDailyMixWeights()
|
||||
|
||||
if w.SimilarityWeight <= daily.SimilarityWeight {
|
||||
t.Errorf("songs_like SimilarityWeight %.2f is not above daily_mix's %.2f",
|
||||
w.SimilarityWeight, daily.SimilarityWeight)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
songs, day float64
|
||||
}{
|
||||
{"LikeBoost", w.LikeBoost, daily.LikeBoost},
|
||||
{"TasteWeight", w.TasteWeight, daily.TasteWeight},
|
||||
{"RecencyWeight", w.RecencyWeight, daily.RecencyWeight},
|
||||
} {
|
||||
if tc.songs >= tc.day {
|
||||
t.Errorf("songs_like %s (%.2f) is not demoted below daily_mix (%.2f); "+
|
||||
"these are the seed-independent terms that made the mix wander",
|
||||
tc.name, tc.songs, tc.day)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -178,19 +179,86 @@ func TestReleaseWorkflow_UsesTheSharedDerivation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// devArm returns the branch of "Compute image tags" that handles refs/heads/dev.
|
||||
func devArm(t *testing.T, yaml string) string {
|
||||
// jobBoundary matches a blank line followed by job-level (two-space)
|
||||
// indentation — the end of the last step in a job.
|
||||
var jobBoundary = regexp.MustCompile(`\n\n [^ \n]`)
|
||||
|
||||
// stepBody returns one workflow step's text, from its `- name:` line to the
|
||||
// start of the next step or the next job.
|
||||
//
|
||||
// The naive cut — "up to the next `- name:`" — silently returns an EMPTY body
|
||||
// for the last step in a job, and every assertion over it then passes
|
||||
// vacuously. That is the failure rule 167 names: a check that reads as
|
||||
// coverage while asserting on nothing. Cutting at a blank line followed by
|
||||
// job-level indentation handles the last-step case, which is exactly where
|
||||
// `Build and push` sits.
|
||||
func stepBody(t *testing.T, yaml, name string) string {
|
||||
t.Helper()
|
||||
const marker = `elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then`
|
||||
i := strings.Index(yaml, marker)
|
||||
i := strings.Index(yaml, "- name: "+name)
|
||||
if i < 0 {
|
||||
t.Fatal("no refs/heads/dev arm in Compute image tags — the dev channel is not wired")
|
||||
t.Fatalf("no %q step in release.yml", name)
|
||||
}
|
||||
rest := yaml[i+len(marker):]
|
||||
if j := strings.Index(rest, "\n else"); j >= 0 {
|
||||
return rest[:j]
|
||||
body := yaml[i:]
|
||||
end := len(body)
|
||||
if j := strings.Index(body, "\n - name:"); j >= 0 {
|
||||
end = j
|
||||
}
|
||||
return rest
|
||||
// A blank line followed by EXACTLY two spaces and then content starts a
|
||||
// new job or job-level comment. The "exactly" matters: a blank line inside
|
||||
// a `run:` block is followed by ten-space indentation and would otherwise
|
||||
// match, truncating the step mid-body — which is how this helper first
|
||||
// sliced the verify step down to its first two lines.
|
||||
if loc := jobBoundary.FindStringIndex(body); loc != nil && loc[0] < end {
|
||||
end = loc[0]
|
||||
}
|
||||
body = body[:end]
|
||||
if strings.TrimSpace(strings.TrimPrefix(body, "- name: "+name)) == "" {
|
||||
t.Fatalf("step %q sliced to an empty body — the assertions below would pass vacuously", name)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// releaseYAML reads the workflow once per test.
|
||||
func releaseYAML(t *testing.T) string {
|
||||
t.Helper()
|
||||
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
// imageTagArms splits "Compute image tags" into its three ref branches. The
|
||||
// whole tag policy lives in that if/elif/else, so the arms are the unit worth
|
||||
// asserting on — a tag published from the wrong arm reaches the wrong
|
||||
// audience, and every such mistake still builds and still pushes a valid
|
||||
// image.
|
||||
func imageTagArms(t *testing.T, yaml string) (tagArm, devArm, mainArm string) {
|
||||
t.Helper()
|
||||
const (
|
||||
tagMarker = `if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then`
|
||||
devMarker = `elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then`
|
||||
mainMarker = "\n else"
|
||||
endMarker = "\n fi"
|
||||
)
|
||||
iTag := strings.Index(yaml, tagMarker)
|
||||
iDev := strings.Index(yaml, devMarker)
|
||||
iMain := -1
|
||||
if iDev >= 0 {
|
||||
if k := strings.Index(yaml[iDev:], mainMarker); k >= 0 {
|
||||
iMain = iDev + k
|
||||
}
|
||||
}
|
||||
if iTag < 0 || iDev < 0 || iMain < 0 {
|
||||
t.Fatal("Compute image tags no longer has a tag/dev/main arm — the tag policy has been restructured, so these guards are pinning nothing")
|
||||
}
|
||||
iEnd := iMain
|
||||
if k := strings.Index(yaml[iMain:], endMarker); k >= 0 {
|
||||
iEnd = iMain + k
|
||||
} else {
|
||||
t.Fatal("no closing fi after the main arm")
|
||||
}
|
||||
return yaml[iTag:iDev], yaml[iDev:iMain], yaml[iMain:iEnd]
|
||||
}
|
||||
|
||||
// The worst regression this wiring can produce: a dev push that also moves
|
||||
@@ -198,11 +266,7 @@ func devArm(t *testing.T, yaml string) string {
|
||||
// next pull. Nothing else in the suite would notice — the build stays green
|
||||
// and the image is valid, it is simply the wrong audience.
|
||||
func TestDevChannel_PublishesDevAloneAndNeverLatest(t *testing.T) {
|
||||
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
arm := devArm(t, string(body))
|
||||
_, arm, _ := imageTagArms(t, releaseYAML(t))
|
||||
|
||||
if !strings.Contains(arm, "${IMAGE}:dev") {
|
||||
t.Errorf("dev arm does not publish :dev\n%s", arm)
|
||||
@@ -286,3 +350,369 @@ func TestBundleStep_GrepsCannotKillTheStep(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The rollback unit, and the reason it is worth a guard: it is invisible until
|
||||
// the moment it is needed. Nothing pulls :<sha> during normal operation, so if
|
||||
// this arm stopped minting one, every build would stay green and every image
|
||||
// would be valid — and the absence would surface only during an incident, as
|
||||
// "there is nothing to roll back to."
|
||||
//
|
||||
// Rules 145 and 147: main push → :latest + :<sha>.
|
||||
func TestMainChannel_PublishesLatestAndTheRollbackUnit(t *testing.T) {
|
||||
_, _, arm := imageTagArms(t, releaseYAML(t))
|
||||
|
||||
if !strings.Contains(arm, "${IMAGE}:latest") {
|
||||
t.Errorf("main arm does not move :latest — production would stop tracking main's tip\n%s", arm)
|
||||
}
|
||||
if !strings.Contains(arm, "${IMAGE}:${GITHUB_SHA}") {
|
||||
t.Errorf("main arm publishes no commit-addressable image; there is no rollback target for production commits\n%s", arm)
|
||||
}
|
||||
// Rule 147: :latest tracks main's tip, and a second name for the same
|
||||
// image sends readers looking for a distinction that does not exist.
|
||||
if strings.Contains(arm, "${IMAGE}:main") {
|
||||
t.Errorf("main arm publishes :main — rule 147 says that tag should not exist\n%s", arm)
|
||||
}
|
||||
}
|
||||
|
||||
// A release refreshes the CHANNEL and mints nothing else (rules 145 + 146).
|
||||
//
|
||||
// The specific regression: re-adding :<sha> here. The tag build rebuilds the
|
||||
// SAME SOURCE as main's build minutes earlier, differing only in which APK is
|
||||
// baked in — so a :<sha> minted here would overwrite main's immutable rollback
|
||||
// target with different contents, under the same name. That is the exact thing
|
||||
// rule 145's immutability clause exists to prevent, and it is the half with
|
||||
// the incidents behind it.
|
||||
func TestReleaseBuild_RefreshesTheChannelAndMintsNothingElse(t *testing.T) {
|
||||
arm, _, _ := imageTagArms(t, releaseYAML(t))
|
||||
|
||||
if !strings.Contains(arm, "${IMAGE}:latest") {
|
||||
t.Errorf("tag arm does not refresh :latest — the channel would keep serving the PREVIOUS release's APK until someone pushed to main\n%s", arm)
|
||||
}
|
||||
if strings.Contains(arm, "GITHUB_SHA") {
|
||||
t.Errorf("tag arm mints a :<sha> image; that would re-push main's immutable rollback target with different bundled contents\n%s", arm)
|
||||
}
|
||||
}
|
||||
|
||||
// No version-numbered image tags anywhere, on any arm (rule 145, and the
|
||||
// operator's 2026-09-10 decision to drop them across every project).
|
||||
//
|
||||
// Asserted across the whole step rather than per-arm because the mistake this
|
||||
// catches is re-adding one ANYWHERE, and the tag arm is only the likeliest
|
||||
// spot. `${VERSION}` still legitimately appears in the step as the build's
|
||||
// self-reported version, so the assertion has to name the image-tag form
|
||||
// specifically rather than the variable — otherwise it would fire on correct
|
||||
// code and get "fixed" by deleting the guard.
|
||||
func TestNoVersionNumberedImageTags(t *testing.T) {
|
||||
yaml := releaseYAML(t)
|
||||
tagArm, devArm, mainArm := imageTagArms(t, yaml)
|
||||
|
||||
for _, tc := range []struct{ name, arm string }{
|
||||
{"tag", tagArm}, {"dev", devArm}, {"main", mainArm},
|
||||
} {
|
||||
for _, forbidden := range []string{
|
||||
"${IMAGE}:${VERSION}",
|
||||
"${IMAGE}:v",
|
||||
"${IMAGE}:${GITHUB_REF#refs/tags/}",
|
||||
} {
|
||||
if strings.Contains(tc.arm, forbidden) {
|
||||
t.Errorf("%s arm publishes a version-numbered image tag (%q); git and the build's self-reported version answer \"which build is this\"\n%s",
|
||||
tc.name, forbidden, tc.arm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The verify job asserted the :<version> image existed. With version tags
|
||||
// gone that assertion would fail every release for a tag nothing mints — so
|
||||
// this pins that it was re-pointed rather than deleted, since deleting it is
|
||||
// the tempting way to make a failing guard go green.
|
||||
func TestVerifyJob_ChecksTheRollbackImageNotAVersionTag(t *testing.T) {
|
||||
yaml := releaseYAML(t)
|
||||
|
||||
if !strings.Contains(yaml, "- name: Rollback image must exist for the tagged commit") {
|
||||
t.Fatal("the release-verification step that checks an image exists is gone; an image push that silently did not happen would now pass verification")
|
||||
}
|
||||
step := stepBody(t, yaml, "Rollback image must exist for the tagged commit")
|
||||
if !strings.Contains(step, "${IMAGE}:${GITHUB_SHA}") {
|
||||
t.Errorf("the verify step does not inspect the commit's rollback image\n%s", step)
|
||||
}
|
||||
if strings.Contains(step, "${IMAGE}:${TAG}") {
|
||||
t.Errorf("the verify step still inspects a version-numbered image, which is no longer published — this would fail every release\n%s", step)
|
||||
}
|
||||
}
|
||||
|
||||
// The server's self-reported version is now the ONLY thing that identifies a
|
||||
// build, so a lane that stamps a channel word instead of a version silently
|
||||
// removes that ability. It used to stamp the literal "main"/"dev".
|
||||
func TestImageBuild_StampsADerivedVersionAndAChannel(t *testing.T) {
|
||||
yaml := releaseYAML(t)
|
||||
|
||||
step := stepBody(t, yaml, "Build and push")
|
||||
for _, want := range []string{
|
||||
"MINSTREL_VERSION=",
|
||||
"MINSTREL_CHANNEL=",
|
||||
} {
|
||||
if !strings.Contains(step, want) {
|
||||
t.Errorf("Build and push does not pass %s — the image cannot report which build it is\n%s", want, step)
|
||||
}
|
||||
}
|
||||
|
||||
// The version must come from the shared derivation, not from the ref.
|
||||
// Reading it off GITHUB_REF is what produced "main" and "dev" as version
|
||||
// strings, which is the regression this pins.
|
||||
_, _, mainArm := imageTagArms(t, yaml)
|
||||
if strings.Contains(mainArm, `version=main`) {
|
||||
t.Errorf("the main arm stamps the literal string \"main\" as a version; two images months apart would be indistinguishable\n%s", mainArm)
|
||||
}
|
||||
if !strings.Contains(yaml, "ci/version.sh HEAD | sed") {
|
||||
t.Error("the image job no longer derives its version from ci/version.sh — the version and the APK's version can now drift apart")
|
||||
}
|
||||
}
|
||||
|
||||
// gitRepo builds a throwaway repo and returns its path. Commit timestamps are
|
||||
// pinned so the derivation is deterministic.
|
||||
func gitRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
run := func(args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
run("-c", "init.defaultBranch=main", "init", "-q")
|
||||
run("config", "user.email", "t@example.invalid")
|
||||
run("config", "user.name", "t")
|
||||
return dir
|
||||
}
|
||||
|
||||
// commitFile writes path and commits it with a pinned committer timestamp.
|
||||
func commitFile(t *testing.T, dir, path, epoch string) {
|
||||
t.Helper()
|
||||
full := filepath.Join(dir, path)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Content must DIFFER from any earlier write to the same path, or git
|
||||
// records nothing and the commit silently covers fewer files than the
|
||||
// test believes. That is not hypothetical: it made the
|
||||
// source-alongside-test case pass vacuously.
|
||||
if err := os.WriteFile(full, []byte("content @"+epoch+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, args := range [][]string{{"add", "-A"}, {"commit", "-q", "-m", path}} {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
|
||||
"GIT_AUTHOR_DATE=@"+epoch+" +0000",
|
||||
"GIT_COMMITTER_DATE=@"+epoch+" +0000",
|
||||
)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// versionIn runs ci/version.sh inside dir, reading real git rather than the
|
||||
// pinned-clock override, so the PATHSPEC is what is under test.
|
||||
func versionIn(t *testing.T, dir string) (string, error) {
|
||||
t.Helper()
|
||||
cmd := exec.Command(filepath.Join(repoRoot(t), "ci", "version.sh"), "HEAD")
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
|
||||
"MINSTREL_NOW_EPOCH=1789000920",
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// The version names what SHIPPED, so a commit that changes nothing shippable
|
||||
// must not move it.
|
||||
//
|
||||
// The failure this prevents is not the cosmetic one. The derivation is a
|
||||
// denylist precisely so that new content counts by default: the direction that
|
||||
// matters is a changed artifact keeping its OLD version, silently, on a green
|
||||
// run. This test pins the cheap half of that (CI-only commits are inert) and,
|
||||
// in the same breath, that a source commit still moves it — because a pathspec
|
||||
// typo that excluded everything would satisfy the first assertion alone.
|
||||
func TestVersionName_IgnoresCommitsThatShipNothing(t *testing.T) {
|
||||
const (
|
||||
shipped = "1757443736" // 2025-09-09T18:48:56Z
|
||||
ciOnly = "1789000920" // 2026-09-10T00:42:00Z, later
|
||||
)
|
||||
dir := gitRepo(t)
|
||||
commitFile(t, dir, "internal/server/thing.go", shipped)
|
||||
commitFile(t, dir, ".gitea/workflows/release.yml", ciOnly)
|
||||
|
||||
out, err := versionIn(t, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("version.sh failed: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "name=2025.09.09.1848") {
|
||||
t.Errorf("a CI-only commit moved the version — the pathspec is not excluding it\n%s", out)
|
||||
}
|
||||
|
||||
// ...and the pathspec must not be so broad it excludes everything.
|
||||
commitFile(t, dir, "internal/server/other.go", ciOnly)
|
||||
out, err = versionIn(t, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("version.sh failed: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "name=2026.09.10.0042") {
|
||||
t.Errorf("a source commit did NOT move the version — the pathspec excludes too much, which is the silent-lie direction\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// android/ is deliberately NOT excluded, and that is the subtle half of the
|
||||
// list. It ships in no server image — but it is the APK's entire source, and
|
||||
// ONE script derives the version for both artifacts. Excluding it would stop
|
||||
// an Android-only commit from moving the APK's own version, which is exactly
|
||||
// the silent downgrade the versioning rework exists to prevent.
|
||||
func TestVersionName_AndroidSourcesCount(t *testing.T) {
|
||||
dir := gitRepo(t)
|
||||
commitFile(t, dir, "internal/server/thing.go", "1757443736")
|
||||
commitFile(t, dir, "android/app/src/main/Thing.kt", "1789000920")
|
||||
|
||||
out, err := versionIn(t, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("version.sh failed: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "name=2026.09.10.0042") {
|
||||
t.Errorf("an Android commit did not move the version; the APK would ship new code under its old version name\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// No shipped commit in range means a shallow clone, and the script must refuse
|
||||
// rather than emit something plausible. A wrong version builds, signs and
|
||||
// publishes perfectly happily; it surfaces later as an update channel that has
|
||||
// quietly stopped offering anything.
|
||||
func TestVersionScript_RefusesWhenNothingShippedIsInRange(t *testing.T) {
|
||||
dir := gitRepo(t)
|
||||
commitFile(t, dir, "ci/version.sh", "1789000920")
|
||||
|
||||
out, err := versionIn(t, dir)
|
||||
if err == nil {
|
||||
t.Fatalf("script succeeded with no shipped commit in range; it should refuse\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "name=") {
|
||||
t.Errorf("script emitted a version name while refusing — that value could still be consumed\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// commitFiles is commitFile for more than one path in a single commit.
|
||||
func commitFiles(t *testing.T, dir, epoch string, paths ...string) {
|
||||
t.Helper()
|
||||
for _, rel := range paths {
|
||||
full := filepath.Join(dir, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte("content @"+epoch+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{{"add", "-A"}, {"commit", "-q", "-m", strings.Join(paths, " ")}} {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
|
||||
"GIT_AUTHOR_DATE=@"+epoch+" +0000",
|
||||
"GIT_COMMITTER_DATE=@"+epoch+" +0000",
|
||||
)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// gitOutput runs a git command in dir and returns its combined output.
|
||||
func gitOutput(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// Tests do not ship, so a test-only commit must not re-version an artifact.
|
||||
//
|
||||
// `go build` drops *_test.go outright and the Vite build never imports a
|
||||
// .test.ts, so neither reaches an image or an APK. This repo has no tests/
|
||||
// tree — Go tests sit inline beside the code — so the exclusions are globs,
|
||||
// and a glob is easy to get subtly wrong in a way that still looks right.
|
||||
func TestVersionName_TestsDoNotReVersionTheArtifact(t *testing.T) {
|
||||
const (
|
||||
shipped = "1757443736" // 2025.09.09.1848
|
||||
later = "1789000920" // 2026.09.10.0042
|
||||
)
|
||||
for _, tc := range []struct{ name, path string }{
|
||||
{"go test beside its source", "internal/server/thing_test.go"},
|
||||
{"web unit test", "web/src/lib/api/admin.test.ts"},
|
||||
{"web script test", "web/scripts/tokens-to-css.test.js"},
|
||||
{"android JVM unit test", "android/app/src/test/java/A.kt"},
|
||||
{"vitest harness config", "web/vitest.config.ts"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := gitRepo(t)
|
||||
commitFile(t, dir, "internal/server/thing.go", shipped)
|
||||
commitFile(t, dir, tc.path, later)
|
||||
|
||||
out, err := versionIn(t, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("version.sh failed: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "name=2025.09.09.1848") {
|
||||
t.Errorf("a commit touching only %s moved the version; tests do not ship\n%s", tc.path, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The direction that actually costs something, and the reason the exclusions
|
||||
// above are globs over FILES rather than over their directories.
|
||||
//
|
||||
// `':!internal'` would satisfy every assertion in the test above while
|
||||
// silently excluding the entire server. This pins the opposite: a commit that
|
||||
// changes a test AND the source under it must still move the version, because
|
||||
// the source path matches on its own. Without this, a too-broad exclusion
|
||||
// reads as a passing test suite and ships an artifact under a stale version.
|
||||
func TestVersionName_ATestAlongsideItsSourceStillCounts(t *testing.T) {
|
||||
const (
|
||||
shipped = "1757443736"
|
||||
later = "1789000920"
|
||||
)
|
||||
dir := gitRepo(t)
|
||||
commitFile(t, dir, "internal/server/thing.go", shipped)
|
||||
commitFiles(t, dir, later,
|
||||
"internal/server/thing.go",
|
||||
"internal/server/thing_test.go",
|
||||
)
|
||||
|
||||
// The commit must actually contain BOTH paths. Rewriting a file with
|
||||
// identical bytes records nothing, and this assertion would then be
|
||||
// checking a test-only commit while appearing to check a mixed one.
|
||||
touched := gitOutput(t, dir, "show", "--name-only", "--format=", "HEAD")
|
||||
for _, want := range []string{"internal/server/thing.go", "internal/server/thing_test.go"} {
|
||||
if !strings.Contains(touched, want) {
|
||||
t.Fatalf("fixture is wrong: HEAD does not contain %s\n%s", want, touched)
|
||||
}
|
||||
}
|
||||
|
||||
out, err := versionIn(t, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("version.sh failed: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "name=2026.09.10.0042") {
|
||||
t.Errorf("a source change accompanied by a test change did NOT move the version — "+
|
||||
"the exclusions are matching directories rather than test files\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ func (s *Server) Router() http.Handler {
|
||||
if raErr != nil {
|
||||
s.Logger.Warn("reacquisition settings unavailable; serving defaults", "err", raErr)
|
||||
}
|
||||
lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn)
|
||||
lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn, s.DataDir)
|
||||
tracksSvc := tracks.NewService(s.Pool, s.Logger, lidarrUnmonitorAdapter{fn: lidarrClientFn}, s.DataDir)
|
||||
playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir)
|
||||
smtpSender := mailer.NewSMTPSender(s.Pool, s.Logger.With("component", "mailer"))
|
||||
@@ -218,6 +218,7 @@ func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
"version": ServerVersion,
|
||||
"channel": ServerChannel,
|
||||
"min_client_version": MinClientVersion,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,12 +5,27 @@ package server
|
||||
// older clients see version_too_old at /healthz and refuse to operate.
|
||||
const MinClientVersion = "0.1.0"
|
||||
|
||||
// ServerVersion is the deployed server image's version tag. Defaults to
|
||||
// "dev" for local builds; overridden at link time via:
|
||||
// ServerVersion is the build's own version name — YYYY.MM.DD.HHMM, derived
|
||||
// from the commit it was built from by ci/version.sh. Defaults to "dev" for
|
||||
// local builds; overridden at link time via:
|
||||
//
|
||||
// -ldflags="-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=v2026.05.10.2'"
|
||||
// -ldflags="-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=2026.09.10.1449'"
|
||||
//
|
||||
// release.yml passes the git tag through MINSTREL_VERSION build-arg →
|
||||
// Dockerfile ldflag. Surfaced at /healthz so operators can verify which
|
||||
// image their container is running without exec'ing into it.
|
||||
// release.yml passes it through the MINSTREL_VERSION build-arg → Dockerfile
|
||||
// ldflag. Surfaced at /healthz so operators can verify which image their
|
||||
// container is running without exec'ing into it.
|
||||
//
|
||||
// This carried the literal strings "main" and "dev" until 2026-09-10, which
|
||||
// made every image on a channel report the same thing forever. It stopped
|
||||
// being cosmetic when :vYYYY.MM.DD.HHMM image tags were retired (family rule
|
||||
// 145): this is now the ONLY thing that says which build is running.
|
||||
var ServerVersion = "dev"
|
||||
|
||||
// ServerChannel is which line this build came off — "stable" or "dev", or
|
||||
// "local" for a plain `docker build`.
|
||||
//
|
||||
// A SIBLING FIELD, never a suffix inside ServerVersion (family rule 149). The
|
||||
// same commit built on both lanes reports the same version and differs only
|
||||
// here; folding the two together is what makes a version string stop being
|
||||
// comparable.
|
||||
var ServerChannel = "local"
|
||||
|
||||
+26
-74
@@ -1,9 +1,9 @@
|
||||
// Package tracks owns the track-level admin actions exposed by the
|
||||
// M7 #372 track-actions menu. Today that's RemoveTrack: the destructive
|
||||
// part is always handled directly by Minstrel (os.Remove + DB delete +
|
||||
// cascade); when the operator opts in via `unmonitor=true` the service
|
||||
// also tells Lidarr to flip the track's monitored flag off so Lidarr
|
||||
// doesn't search for a replacement.
|
||||
// Package tracks owns the track-level admin actions behind DELETE
|
||||
// /api/admin/tracks/{id}. Today that's RemoveTrack: the destructive part goes
|
||||
// through library.DeleteTrackFile — the one path that deletes a track file —
|
||||
// and when the operator opts in via `unmonitor=true` the service also tells
|
||||
// Lidarr to flip the track's monitored flag off so Lidarr doesn't search for a
|
||||
// replacement.
|
||||
//
|
||||
// History: an earlier shape (commit 50a231f, since rewritten) routed
|
||||
// Lidarr-managed tracks through lidarrquarantine.DeleteViaLidarr — but
|
||||
@@ -12,6 +12,9 @@
|
||||
// drop sibling tracks the operator didn't ask to remove. The current
|
||||
// shape per spec revision 723eee9 is "always direct delete; opt-in
|
||||
// Lidarr unmonitor for replacement-suppression."
|
||||
//
|
||||
// The web track-kebab entry that called this was removed in f7278f24; the
|
||||
// endpoint was kept deliberately for a safer admin surface to rebind.
|
||||
package tracks
|
||||
|
||||
import (
|
||||
@@ -19,15 +22,14 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when the track id doesn't resolve. Aliased
|
||||
@@ -69,10 +71,10 @@ func NewService(pool *pgxpool.Pool, logger *slog.Logger, lidarr LidarrUnmonitore
|
||||
return &Service{pool: pool, logger: logger, lidarr: lidarr, dataDir: dataDir}
|
||||
}
|
||||
|
||||
// RemoveTrack deletes the file from disk and the DB rows, runs the
|
||||
// album-empty / artist-empty cascade tidy-up, and (when unmonitor is
|
||||
// true and the track is Lidarr-managed) tells Lidarr to flip the track's
|
||||
// monitored flag off so it won't search for a replacement.
|
||||
// RemoveTrack deletes the track's file and then its row, tidies away an album
|
||||
// or artist the delete empties, and (when unmonitor is true and the track is
|
||||
// Lidarr-managed) tells Lidarr to flip the track's monitored flag off so it
|
||||
// won't search for a replacement.
|
||||
//
|
||||
// Returns:
|
||||
// - deletedAlbumID: non-nil when removing the track left the album
|
||||
@@ -81,9 +83,10 @@ func NewService(pool *pgxpool.Pool, logger *slog.Logger, lidarr LidarrUnmonitore
|
||||
// empty (only set if deletedAlbumID is also set).
|
||||
// - lidarrUnmonitorFailed: true when the operator requested unmonitor
|
||||
// and the Lidarr call failed; the file + DB delete still succeeded.
|
||||
// - err: only for failures *before* the destructive part completes.
|
||||
// A failed os.Remove is logged and tolerated. A failed Lidarr
|
||||
// unmonitor is reflected in the bool flag, not the error.
|
||||
// - err: ErrNotFound, or a failure before anything was deleted. When the
|
||||
// file cannot be removed it is a *library.FileRemoveError and NOTHING was
|
||||
// deleted — see library.DeleteTrackFile for why that order is the contract
|
||||
// (#3918). A failed Lidarr unmonitor is reflected in the bool, not here.
|
||||
//
|
||||
// adminID is currently unused — the cascade audit-log line that would
|
||||
// reference it isn't wired in this slice. It's threaded through the
|
||||
@@ -104,10 +107,9 @@ func (s *Service) RemoveTrack(
|
||||
return nil, nil, false, fmt.Errorf("get track: %w", err)
|
||||
}
|
||||
|
||||
// Capture the album's mbid *before* the cascade-delete transaction.
|
||||
// If removing this track empties the album, DeleteAlbumIfEmpty
|
||||
// removes the row and a post-commit GetAlbumByID would return
|
||||
// pgx.ErrNoRows — leaving the unmonitor walk with no album mbid.
|
||||
// Capture the album's mbid *before* the delete. If removing this track
|
||||
// empties the album, its row is gone afterwards and the unmonitor walk
|
||||
// would have no album mbid to name.
|
||||
var albumMbid string
|
||||
if track.Mbid != nil && *track.Mbid != "" && unmonitor && s.lidarr != nil {
|
||||
alb, alerr := q.GetAlbumByID(ctx, track.AlbumID)
|
||||
@@ -118,64 +120,14 @@ func (s *Service) RemoveTrack(
|
||||
// "no albumMbid → can't unmonitor → flag failure."
|
||||
}
|
||||
|
||||
// Always: remove the file. Tolerate already-missing.
|
||||
if track.FilePath != "" {
|
||||
if rerr := os.Remove(track.FilePath); rerr != nil && !errors.Is(rerr, os.ErrNotExist) {
|
||||
s.logger.Warn("track delete: file remove failed",
|
||||
"path", track.FilePath, "track_id", trackID, "err", rerr)
|
||||
// Proceed: DB consistency is the priority.
|
||||
deleted, err := library.DeleteTrackFile(ctx, s.pool, s.logger, s.dataDir, trackID)
|
||||
if err != nil {
|
||||
if errors.Is(err, library.ErrTrackNotFound) {
|
||||
return nil, nil, false, ErrNotFound
|
||||
}
|
||||
}
|
||||
|
||||
// DB cleanup in a transaction so a midway failure leaves things consistent.
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, false, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
tq := dbq.New(tx)
|
||||
|
||||
deleted, err := tq.DeleteTrack(ctx, trackID)
|
||||
if err != nil {
|
||||
return nil, nil, false, fmt.Errorf("delete track: %w", err)
|
||||
}
|
||||
|
||||
albumRow, aerr := tq.DeleteAlbumIfEmpty(ctx, deleted.AlbumID)
|
||||
switch {
|
||||
case aerr == nil:
|
||||
albumID := albumRow.ID
|
||||
deletedAlbumID = &albumID
|
||||
artistID, arerr := tq.DeleteArtistIfEmpty(ctx, albumRow.ArtistID)
|
||||
switch {
|
||||
case arerr == nil:
|
||||
id := artistID
|
||||
deletedArtistID = &id
|
||||
case errors.Is(arerr, pgx.ErrNoRows):
|
||||
// Artist still has other albums or stray tracks. OK.
|
||||
default:
|
||||
return nil, nil, false, fmt.Errorf("delete artist if empty: %w", arerr)
|
||||
}
|
||||
case errors.Is(aerr, pgx.ErrNoRows):
|
||||
// Album still has other tracks. OK.
|
||||
default:
|
||||
return nil, nil, false, fmt.Errorf("delete album if empty: %w", aerr)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, nil, false, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
|
||||
// Cleanup artist-art filesystem cache if the delete cascade orphaned
|
||||
// an artist. Non-fatal — the destructive part is done; we just want
|
||||
// to keep the dataDir tidy.
|
||||
if deletedArtistID != nil && s.dataDir != "" {
|
||||
if err := coverart.CleanupArtistArt(s.dataDir, *deletedArtistID); err != nil {
|
||||
s.logger.Warn("track delete: artist-art cleanup failed",
|
||||
"artist_id", *deletedArtistID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Lidarr unmonitor — non-fatal. The destructive part is done; any
|
||||
// failure here is informational so the operator can retry manually.
|
||||
if unmonitor && track.Mbid != nil && *track.Mbid != "" && s.lidarr != nil {
|
||||
@@ -193,5 +145,5 @@ func (s *Service) RemoveTrack(
|
||||
}
|
||||
}
|
||||
|
||||
return deletedAlbumID, deletedArtistID, lidarrUnmonitorFailed, nil
|
||||
return deleted.AlbumID, deleted.ArtistID, lidarrUnmonitorFailed, nil
|
||||
}
|
||||
|
||||
@@ -49,3 +49,37 @@ describe('errMessage', () => {
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('errMessage detail codes (#3918)', () => {
|
||||
const detail =
|
||||
'Minstrel runs as uid 1000, gid 1000 and cannot delete from /music/A (read-only file system). ' +
|
||||
'The library mount must be writable by that user. Nothing was deleted.';
|
||||
|
||||
test('library_not_writable appends the server message to the copy', () => {
|
||||
expect(errMessage({ code: 'library_not_writable', message: detail })).toBe(
|
||||
`${ERROR_COPY.library_not_writable} ${detail}`
|
||||
);
|
||||
});
|
||||
|
||||
test('file_delete_failed appends the server message to the copy', () => {
|
||||
const msg = 'Could not delete /music/A/01.mp3 (input/output error). Nothing was deleted.';
|
||||
expect(errMessage({ code: 'file_delete_failed', message: msg })).toBe(
|
||||
`${ERROR_COPY.file_delete_failed} ${msg}`
|
||||
);
|
||||
});
|
||||
|
||||
test('a detail code with no message shows the copy alone', () => {
|
||||
expect(errMessage({ code: 'library_not_writable' })).toBe(ERROR_COPY.library_not_writable);
|
||||
expect(errMessage({ code: 'library_not_writable', message: ' ' })).toBe(
|
||||
ERROR_COPY.library_not_writable
|
||||
);
|
||||
});
|
||||
|
||||
// Server messages are usually internal detail. Appending them for every code
|
||||
// would leak things like driver errors into toasts; this pins the scope.
|
||||
test('other codes never carry the server message', () => {
|
||||
expect(errMessage({ code: 'track_not_found', message: 'pgx: no rows in result set' })).toBe(
|
||||
ERROR_COPY.track_not_found
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,11 +8,25 @@ export function errCode(err: unknown): string {
|
||||
return (err as { code?: string })?.code ?? 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Codes whose server message carries specifics the operator needs in order to
|
||||
* act — which directory, which uid — that fixed copy cannot say. For these the
|
||||
* message follows the copy (#3918). Kept to a named list on purpose: most
|
||||
* server messages are internal detail and must never reach a toast. Mirrored
|
||||
* in Android's ErrorCopy.
|
||||
*/
|
||||
const DETAIL_CODES: ReadonlySet<string> = new Set(['library_not_writable', 'file_delete_failed']);
|
||||
|
||||
/**
|
||||
* Returns user-facing copy for an unknown error value. Looks up the
|
||||
* error's code in the error-copy map; falls back to the supplied
|
||||
* fallback (default: "Something went wrong.") when the code is unknown.
|
||||
* For a DETAIL_CODES code, the server's message is appended.
|
||||
*/
|
||||
export function errMessage(err: unknown, fallback = 'Something went wrong.'): string {
|
||||
return copyForCode(errCode(err)) ?? fallback;
|
||||
const code = errCode(err);
|
||||
const copy = copyForCode(code) ?? fallback;
|
||||
if (!DETAIL_CODES.has(code)) return copy;
|
||||
const detail = (err as { message?: unknown })?.message;
|
||||
return typeof detail === 'string' && detail.trim() !== '' ? `${copy} ${detail}` : copy;
|
||||
}
|
||||
|
||||
@@ -34,14 +34,17 @@ export type DiscoverTuning = {
|
||||
snooze_days: number;
|
||||
};
|
||||
|
||||
export type TuningScope = 'radio' | 'daily_mix' | 'taste' | 'discover';
|
||||
export type TuningScope = 'radio' | 'daily_mix' | 'songs_like' | 'taste' | 'discover';
|
||||
|
||||
/** The weight-profile scopes, as distinct from the singleton-settings scopes. */
|
||||
export type WeightProfileScope = 'radio' | 'daily_mix' | 'songs_like';
|
||||
|
||||
export type TuningSnapshot = {
|
||||
profiles: Record<'radio' | 'daily_mix', WeightProfile>;
|
||||
profiles: Record<WeightProfileScope, WeightProfile>;
|
||||
taste: TasteTuning;
|
||||
discover: DiscoverTuning;
|
||||
shipped: {
|
||||
profiles: Record<'radio' | 'daily_mix', WeightProfile>;
|
||||
profiles: Record<WeightProfileScope, WeightProfile>;
|
||||
taste: TasteTuning;
|
||||
discover: DiscoverTuning;
|
||||
};
|
||||
|
||||
@@ -6,26 +6,36 @@
|
||||
// is actually running (came up debugging the in-app update flow when
|
||||
// it wasn't obvious whether v2026.05.10.0 or .1 was deployed).
|
||||
//
|
||||
// This is now the ONLY place an operator can see which build they are on.
|
||||
// Image tags stopped carrying the version on 2026-09-10 — :latest and :dev
|
||||
// are rolling names and :<sha> answers "which commit", not "which build" —
|
||||
// so the server's self-report is the answer.
|
||||
//
|
||||
// The channel is shown BESIDE the version, never spliced into it: the same
|
||||
// commit built on both lanes reports an identical version and differs only
|
||||
// in channel, so "2026.09.10.1449 · dev" and "2026.09.10.1449 · stable" are
|
||||
// the same code on two lines. Suppressed for stable, which is the
|
||||
// unremarkable case and would just be noise on every install.
|
||||
//
|
||||
// /healthz is unauthenticated, so the bare fetch works without
|
||||
// credentials. Renders nothing on parse failure or pre-version
|
||||
// images that don't include the field — graceful degradation.
|
||||
|
||||
type Health = { status: string; version?: string };
|
||||
type Health = { status: string; version?: string; channel?: string };
|
||||
|
||||
let version = $state<string | null>(null);
|
||||
let channel = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await fetch('/healthz');
|
||||
if (!res.ok) return;
|
||||
const body = (await res.json()) as Partial<Health>;
|
||||
if (body.version && body.version !== 'dev') {
|
||||
version = body.version;
|
||||
} else if (body.version === 'dev') {
|
||||
// Local dev images report "dev" — show it so the operator
|
||||
// can tell they're not on a release tag.
|
||||
version = 'dev';
|
||||
}
|
||||
if (!body.version) return;
|
||||
version = body.version;
|
||||
// Reported verbatim rather than validated against an enum — a build
|
||||
// claiming something unexpected is better shown than dropped.
|
||||
channel = body.channel && body.channel !== 'stable' ? body.channel : null;
|
||||
} catch {
|
||||
// network / parse error — silent.
|
||||
}
|
||||
@@ -33,5 +43,7 @@
|
||||
</script>
|
||||
|
||||
{#if version}
|
||||
<p class="text-xs text-text-secondary">Server {version}</p>
|
||||
<p class="text-xs text-text-secondary">
|
||||
Server {version}{#if channel} · {channel}{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
"request_not_pending": "This request is no longer pending.",
|
||||
"request_not_found": "That request no longer exists.",
|
||||
"track_not_found": "That track no longer exists.",
|
||||
"library_not_writable": "The music library isn't writable by the server.",
|
||||
"file_delete_failed": "The file couldn't be deleted.",
|
||||
"album_not_found": "That album no longer exists.",
|
||||
"artist_not_found": "That artist no longer exists.",
|
||||
"playlist_not_found": "That playlist no longer exists.",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
resetTuning,
|
||||
getTrends,
|
||||
type TuningScope,
|
||||
type WeightProfileScope,
|
||||
type TuningSnapshot,
|
||||
type WeightProfile,
|
||||
type TasteTuning,
|
||||
@@ -49,9 +50,15 @@
|
||||
{ key: 'snooze_days', label: 'Snooze length (days)', hint: 'How long "not right now" parks a suggestion before it returns on its own. Records no opinion about the artist and never feeds the taste profile.' }
|
||||
];
|
||||
|
||||
const profileScopes: { scope: 'radio' | 'daily_mix'; label: string; blurb: string }[] = [
|
||||
const profileScopes: { scope: WeightProfileScope; label: string; blurb: string }[] = [
|
||||
{ scope: 'radio', label: 'Radio', blurb: 'Seed-directed listening — the user picked a direction.' },
|
||||
{ scope: 'daily_mix', label: 'Daily mixes', blurb: 'For You, Songs like…, and the discovery mixes.' }
|
||||
{ scope: 'daily_mix', label: 'Daily mixes', blurb: 'For You, the discovery mixes, and "You might like".' },
|
||||
{
|
||||
scope: 'songs_like',
|
||||
label: 'Songs like…',
|
||||
blurb:
|
||||
'The tightest surface: everything here should sound like the seed track. Similarity dominates on purpose — raising like/taste/recency here is what makes these mixes wander.'
|
||||
}
|
||||
];
|
||||
|
||||
let snapshot = $state<TuningSnapshot | null>(null);
|
||||
@@ -64,10 +71,11 @@
|
||||
const f: Record<string, Record<string, string>> = {
|
||||
radio: {},
|
||||
daily_mix: {},
|
||||
songs_like: {},
|
||||
taste: {},
|
||||
discover: {}
|
||||
};
|
||||
for (const p of ['radio', 'daily_mix'] as const) {
|
||||
for (const p of profileScopes.map((s) => s.scope)) {
|
||||
for (const { key } of weightFields) f[p][key] = String(snap.profiles[p][key]);
|
||||
}
|
||||
for (const { key } of tasteFields) f.taste[key] = String(snap.taste[key]);
|
||||
|
||||
@@ -55,11 +55,19 @@ const discover = (over: Partial<Record<string, number>> = {}) => ({
|
||||
// new scopes to BOTH this fixture and `shipped`.
|
||||
function snapshot(over: Partial<TuningSnapshot> = {}): TuningSnapshot {
|
||||
return {
|
||||
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
|
||||
profiles: {
|
||||
radio: weights({ taste_weight: 1 }),
|
||||
daily_mix: weights(),
|
||||
songs_like: weights({ similarity_weight: 4, like_boost: 0.5, taste_weight: 0.25 })
|
||||
},
|
||||
taste: taste(),
|
||||
discover: discover(),
|
||||
shipped: {
|
||||
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
|
||||
profiles: {
|
||||
radio: weights({ taste_weight: 1 }),
|
||||
daily_mix: weights(),
|
||||
songs_like: weights({ similarity_weight: 4, like_boost: 0.5, taste_weight: 0.25 })
|
||||
},
|
||||
taste: taste(),
|
||||
discover: discover()
|
||||
},
|
||||
@@ -75,11 +83,12 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('Admin tuning page', () => {
|
||||
test('renders both profiles and the taste card with current values', async () => {
|
||||
test('renders every weight profile and the taste card with current values', async () => {
|
||||
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
||||
render(TuningPage);
|
||||
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
|
||||
expect(screen.getByText('Daily mixes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Songs like…')).toBeInTheDocument();
|
||||
expect(screen.getByText('Taste profile build')).toBeInTheDocument();
|
||||
const radioTaste = screen.getByLabelText(/taste weight/i, {
|
||||
selector: '#radio-taste_weight'
|
||||
@@ -89,6 +98,27 @@ describe('Admin tuning page', () => {
|
||||
expect(halfLife.value).toBe('75');
|
||||
});
|
||||
|
||||
// Songs-like is a separate weight profile precisely so it can be tuned
|
||||
// apart from For-You (#3881). If its card stopped rendering its OWN values
|
||||
// — or quietly fell back to daily_mix's — the split would exist in the
|
||||
// backend and be unreachable in the UI, which is the same as not shipping
|
||||
// it (rule 27).
|
||||
test('the songs-like card carries its own weights, not daily_mix\'s', async () => {
|
||||
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
||||
render(TuningPage);
|
||||
await waitFor(() => expect(screen.getByText('Songs like…')).toBeInTheDocument());
|
||||
|
||||
const sim = document.getElementById('songs_like-similarity_weight') as HTMLInputElement;
|
||||
const like = document.getElementById('songs_like-like_boost') as HTMLInputElement;
|
||||
expect(sim.value).toBe('4');
|
||||
expect(like.value).toBe('0.5');
|
||||
|
||||
// The contrast that makes the card worth having: daily_mix must still show
|
||||
// its own, different numbers on the same page.
|
||||
const dailySim = document.getElementById('daily_mix-similarity_weight') as HTMLInputElement;
|
||||
expect(dailySim.value).not.toBe(sim.value);
|
||||
});
|
||||
|
||||
test('save sends only the changed fields for the scope', async () => {
|
||||
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
||||
(patchTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
||||
|
||||
Reference in New Issue
Block a user