Compare commits

...

21 Commits

Author SHA1 Message Date
bvandeusen 0d410630a2 Merge pull request 'Release v2026.05.18.0 — integration-tests-in-CI + recommendations/cover-art/discover batch' (#51) from dev into main 2026-05-17 22:41:20 -04:00
bvandeusen b76ea66165 chore(flutter): bump version to 2026.5.18+9 for v2026.05.18.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:40:44 -04:00
bvandeusen 412b9e37eb test(coverart): no-MBID enrich settles 'none' (per canonical behavior)
Operator decision: the enricher is canonical. No MBID still runs the
provider chain (name-based providers — Deezer/Last.fm — resolve
without an MBID); if every provider returns ErrNotFound the row
settles cover/artist source 'none' at the current sources version
(re-eligible only when the registered provider set changes). It does
NOT skip-and-leave-NULL.

The two _NoMBID_LeavesNull tests predated the name-based providers
(0020 slice) and asserted the old skip→NULL contract. Updated:
- TestEnrichArtist_NoMBID_SettlesNone: stub now returns ErrNotFound
  (realistic MBID-only-provider-with-empty-MBID), expect source 'none'.
- TestEnrichAlbum_NoSidecarNoMBID_SettlesNone: empty registry →
  allWere404 stays true → expect 'none'.

Last failing cluster from the CI-integration initiative; suite should
now be fully green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:27:01 -04:00
bvandeusen a86b7a5e07 test(coverart): DrainsNullSourceOnly — stamp id2 'none' at current version
The test marked id2 'none' via SetAlbumCover, which (covers.sql:42)
does NOT set cover_art_sources_version. ListAlbumsMissingCover treats
'none' AND version != current as eligible, so id2 (version 0, current
1) was wrongly drained → processed=2. The comment's intent ("'none'
with current version → not drained") requires SetAlbumCoverWithVersion
(albums.sql) stamped with the live GetCurrentSourcesVersion — the same
value EnrichBatch compares against. Test-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:22:44 -04:00
bvandeusen 5e91efe695 test(coverart): fix registry-wipe + stale provider signature
Two distinct pre-existing test bugs in the last failing cluster:

1. newTestEnricher() called resetRegistryForTests() itself, wiping the
   fake providers callers Register() right before calling it (its own
   doc says callers register first and reconcile() picks them up). The
   ~8 TestEnrichArtist_* failures (source stayed NULL, no thumb
   written) all stem from reconcile() seeing an empty registry. Remove
   the internal reset; make every caller that lacked one own the
   registry lifecycle explicitly (resetRegistryForTests + t.Cleanup):
   SidecarFound, NoSidecarNoMBID, AlreadySidecar_NoOp,
   DrainsNullSourceOnly.

2. apiTestAlbumProvider.FetchAlbumCover had a stale signature
   (context, string) predating the AlbumRef refactor; it no longer
   satisfied coverart.AlbumCoverProvider, so the p.(AlbumCoverProvider)
   capability assertion failed and TestAdminListCoverSources got
   supports=[]. Fix the param to coverart.AlbumRef.

Test-only. (A1/A2 were correct; they unmasked these. Any residual
album-enricher semantics failures will be root-caused from the next
clean run, not bundled speculatively.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:05:36 -04:00
bvandeusen 47572b2e95 feat(flutter): Discover suggestions feed — web parity
The Flutter Discover screen was Lidarr-search-only; its empty state
showed a "Type to search" placeholder while web's /discover renders
the LB-derived out-of-library artist SuggestionFeed as its default
surface. Parity gap that slipped #356.

- ArtistSuggestion/SeedContribution model mirroring web types, with
  attributionText() matching web's "Because you liked/played X[, Y, and
  Z]." (Oxford comma, max 3).
- DiscoverApi.listSuggestions() → GET /api/discover/suggestions
  (image_url already resolved server-side from Lidarr, a7bea43).
- discover_screen: empty search box → suggestions feed (artist art +
  name + attribution + Request, reusing the existing createRequest +
  mutation-queue-replay flow with optimistic hide); typing → Lidarr
  search replaces; clearing → suggestions return. Mirrors web exactly.

Flutter-only; server endpoint unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:48:54 -04:00
bvandeusen 53a02322fb fix: A2 (relax art-source CHECK) + D + E integration residual
A2 — migration 0030: the albums/artists art `*_source` CHECK was a
fixed provider-ID allowlist fighting the extensible coverart.Register
registry (per-provider migration churn at 0016/0018/0020; rejected
test stub providers → ~11 enricher tests failed). Relax to "NULL or
non-empty"; the registry is the source of truth. Same brittleness
class as the #433 discovery-mix CHECK.

D — lidarr Approve resolves metadata/quality profiles (JSON arrays)
before the add; the test stubs returned {"id":1} for every path, so
ListMetadataProfiles failed to unmarshal → 500/Approve errors. Make
the lidarrrequests + admin_requests stubs path-aware (arrays for
/metadataprofile, /qualityprofile).

E:
- auth: requireUser (prelude.go) emitted code "auth_required"; the
  canonical code is "unauthenticated" (operator decision). Change the
  code; drop the now-dead "auth_required" web error-copy key
  ("unauthenticated" already has copy).
- playlists_system_test wrapped the real auth.RequireUser middleware
  but only injected withUser() context → 401. Like every other api
  handler test, drop the middleware and rely on requireUser().
- admin_users dup-username test seeded "test-existing" (seedUser
  prefixes) but POSTed "existing" → no collision; POST the prefixed
  name.
- me_timezone test decoded a top-level {"code"} but the envelope is
  {"error":{"code"}}; decode the nested shape.
- audit test asserted compact JSON; Postgres jsonb::text is spaced.
- put-lidarr-config tests predated the missing_defaults gate (correct
  handler behavior); supply the required defaults in the bodies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:22:30 -04:00
bvandeusen 75163ea483 test: correct the A regression + finish B residual (coverart boot, stale system_test)
A redo: the prior commit truncated cover_art_sources_meta, but
SettingsService.reconcile() only READS that singleton (seeded once by
migration 0018) and never recreates it → ~45 coverart/api tests hard-
failed "get current sources version: no rows". Correct reset: keep
cover_art_provider_settings in the truncate set (boot idempotently
re-UpsertProviderSettings), drop cover_art_sources_meta from it, and
instead `UPDATE cover_art_sources_meta SET current_version = 1` so the
row survives while cross-test version accumulation is cleared.

B residual (exposed once the play_events FK fix let these run):
- seedQuarantine used reason 'test-hide', invalid for the
  lidarr_quarantine_reason enum (bad_rip/wrong_file/wrong_tags/
  duplicate/other) → use 'other'.
- TestBuildSystemPlaylists_SufficientActivity predated #411/#352: the
  variant switch errored on the 5 new seedless mixes and capped
  track_count at 25. Accept deep_cuts/rediscover/new_for_you/
  on_this_day/first_listens as seedless; raise the cap to 100.

Test/test-harness only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:05:18 -04:00
bvandeusen d212621eaa test: fix 4 integration-suite root causes surfaced by CI (C+A+B+F)
Latent failures exposed now that integration tests run in CI (#339).
All test/test-harness only — no production code changes.

A (dbtest.ResetDB): also truncate cover_art_provider_settings +
cover_art_sources_meta. The monotonic source-version counter (seeded 1
by 0018) accumulated across internal/coverart tests → CurrentVersion=4
want 1, key-only-bump assertions, enricher source skips. SettingsService
re-seeds both at boot, so a truncated start is the correct fresh state.

B (playlists system_test.seedPlayEvent): inserted play_events with a
random session_id → play_events_session_id_fkey violation (7 tests).
Create the parent play_sessions row in the same statement (CTE).

C (similarity worker_integration_test.newTestWorker): built the client
with only BaseURL. Post-4fca0e6 similarity hits the Labs API via
LabsBaseURL, so an unset LabsBaseURL fell through to the real labs.api
and the stub never ran → 0 similarity rows. Set LabsBaseURL to the stub.

F (library scanner_test): NOT a flake — deterministic. Synthetic
ID3-only MP3s have no decodable duration; the scanner intentionally
won't skip duration_ms=0 rows (retries duration backfill), so every
re-scan reported Updated not Skipped. Seed duration_ms>0 before the
second scan so the mtime-based incremental-skip path under test is
actually exercised. Production scanner behavior unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:32:42 -04:00
bvandeusen 5a048cbea2 fix(ci): serialize integration test packages (-p 1) to stop TRUNCATE deadlocks
First CI integration run proved the act_runner pattern works (service
discovery, migrate, exactly-one guard all functioned) but ~150 tests
failed with `dbtest.ResetDB truncate: deadlock detected (40P01)` plus
cascading FK/dup-key symptoms. Root cause: `go test ./...` runs package
binaries concurrently (default -p = NumCPU); every integration package
TRUNCATEs the single shared minstrel_test DB, so concurrent truncates
deadlock and half-seeded fixtures violate FKs. The documented local
invocation is `go test -p 1 ./...` for exactly this reason. Serialize
package execution in both the CI step and `make test-integration`.

Genuine (non-concurrency) failures will remain after this — never caught
before because integration tests never ran in CI. Triaged next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:22:06 -04:00
bvandeusen 760b4a7c6c feat(cli,ci): admin reset-password + migrate subcommands; test-DB isolation + CI integration job
#321 — `minstrel admin reset-password [-user admin] [-password X]`:
loads config, updates BOTH password_hash (bcrypt) and subsonic_password
(plaintext, for Subsonic t+s) so neither auth path is left stale;
generates+prints a strong password when -password is omitted. Recovers
a locked-out operator without DB surgery. Subcommand dispatch added to
main.go (os.Args switch before flag-parse; server path untouched) plus
a `minstrel migrate` subcommand exposing db.Migrate standalone.

#339 — integration tests no longer truncate the dev DB:
- deploy/initdb creates minstrel_test on a fresh compose volume;
  `make test-integration` ensures it idempotently and points
  MINSTREL_TEST_DATABASE_URL at minstrel_test.
- docker-compose.yml + README updated.

CI integration job (test-go.yml): the prior workflow only ran
`go test -short -race` with no DB, so the entire integration suite
silently t.Skip'd — "CI green" never covered API/db/scanner. New
`integration` job runs the full `go test -race` against an ephemeral
Postgres service, using the act_runner shared-daemon pattern: no
published ports, discover the service container by job-name filter via
the docker socket, reach it by bridge IP, hard exactly-one assertion +
dev-compose-name reject (a wrong target would truncate real data),
TCP-wait, `minstrel migrate`, then test. Fast `test` job kept as the
quick gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:36:36 -04:00
bvandeusen a7bea43a13 feat(discover): on-demand Lidarr artist art for suggestions
Out-of-library suggestion artists (artist_similarity_unmatched rows)
have no local art row, so the Discover card always showed a
placeholder. Resolve art on-demand from Lidarr's artist lookup,
matched by MBID (foreignArtistId == candidate_mbid), and pass the
remote image URL straight through — no caching (a suggestion may
never be viewed; the browser fetches the URL directly).

- suggestionView gains image_url (omitempty); handler resolves it via
  bounded-concurrency Lidarr LookupArtist, best-effort, never fails
  the request.
- Lidarr is the sole source: disabled / unreachable / no-match →
  empty → existing placeholder. (No inline TheAudioDB fallback — it's
  externally rate-limited and there's no cache to amortize it.)
- web: ArtistSuggestion.image_url?; SuggestionFeed passes it to
  DiscoverResultCard (already supports imageUrl).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:16:56 -04:00
bvandeusen 62db8edcdb Merge pull request 'Release v2026.05.16.0 — recommendations working end-to-end + cover-art uncap' (#50) from dev into main 2026-05-16 18:58:55 -04:00
bvandeusen 9ffe33a6f2 chore(flutter): bump version to 2026.5.16+8 for v2026.05.16.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:58:19 -04:00
bvandeusen 3b142e5332 test(server): drop removed coverArtBackfillCap arg from New() calls
Follows 005965d (#388), which removed the coverArtBackfillCap param
from server.New. server_test.go is in package server so it calls New()
unqualified — missed by the signature-caller grep. Updated all 6
positional calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:45:24 -04:00
bvandeusen 005965d6de feat(coverart): #388 remove global cover-art backfill cap
Operator feedback (2026-05-09): the 500-album cap meant cover art rolled
in over many nightly runs even when local sources (sidecar/embedded)
could finish in minutes. Remove the global cap; rely on the existing
per-provider HTTP throttle (coverart httpClient MinInterval) so local
art is disk-speed and remote providers stay TOS-friendly.

- enricher.go / artist_enricher.go: EnrichBatch, EnrichArtistBatch,
  EnrichRetryMissing now treat limit<0 as unbounded (0 still = stage
  disabled). The cap was a SQL LIMIT; unbounded uses max int32.
- main.go: RunScanConfig EnrichCap/ArtistEnrichCap = -1 (unbounded).
- Drop LibraryConfig.CoverArtBackfillCap + the
  MINSTREL_LIBRARY_COVERART_BACKFILL_CAP env var.
- Drop the now-dead coverBackfillCap param threaded through
  server.New + api.Mount + the handlers struct.
- Admin bulk refetch (/api/admin/covers/refetch-missing) now drains
  unbounded; response {queued:int} → {started:bool} (the count is
  unknowable synchronously for a fire-and-forget drain). Web copy +
  client type + Go/web tests updated to match.

No doc refs existed (config.example.yaml / docker-compose / README
never documented the env var).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:41:21 -04:00
bvandeusen 4fca0e66cb fix(listenbrainz): similarity hits Labs API, not the 404'ing main API
Root cause of zero LB recommendations (every similar-recordings AND
similar-artists call returned HTTP 404, worker logs):

- Wrong host/path: client called
  api.listenbrainz.org/1/explore/similar-{recordings,artists}/{mbid}.
  /explore/... is a WEBSITE route, not an API endpoint — it 308s then
  404s. Similarity datasets live on the separate Labs API.
- Invalid algorithm: the hardcoded
  session_…_session_30_…_limit_100_filter_True_… is not a permitted
  Labs enum member (400s) regardless of host.

Verified against the live Labs API:
  GET labs.api.listenbrainz.org/similar-recordings/json
      ?recording_mbids=<mbid>&algorithm=<algo>
  GET labs.api.listenbrainz.org/similar-artists/json
      ?artist_mbids=<mbid>&algorithm=<algo>
  algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30
  → 200 for both. Response field names (recording_mbid/artist_mbid/
  name/score) already match the existing structs — parsing unchanged.

- Add defaultLabsBaseURL + Client.LabsBaseURL (separate from the main
  BaseURL; scrobble submission still uses api.listenbrainz.org).
- Drop the count/limit query param — result size is encoded in the
  algorithm name (limit_50); caller still applies its own top-K.
- Tests: newTestClient sets LabsBaseURL; the two *_LimitParamSet tests
  become *_MbidParamSet (assert the Labs path + mbid query param).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:50:32 -04:00
bvandeusen 4d2aebe3ed test(similarity): update default-batch assertion 5→25
Follows ca1bc5a, which raised the worker batch default. The defaults
test pinned the old value; align it with the intended new default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:51:22 -04:00
bvandeusen ca1bc5af62 perf(similarity): worker batch 5→25; track-MBID backfill uncapped
- similarity.Worker batch 5→25 (tracks AND artists per 1h tick). At
  5/h a freshly-scrobbled library took days to build a usable
  similarity pool; 25/h converges in hours, still well under
  ListenBrainz rate limits (429 aborts the tick).
- scanrun Stage 2b track backfill now runs unbounded (-1) instead of
  reusing the album backfill's 5000 staged cap. It's a one-time
  whole-library heal with no progress UI; a cap just left tracks.mbid
  partially NULL (3634/18056 after one pass) until several future
  scans caught up, re-reading untagged files each time. One uncapped
  pass converges; later scans only re-read the remaining NULL rows.
  Album backfill keeps its 5000 cap (it has staged scan_runs UX).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:24:45 -04:00
bvandeusen e2432caa65 fix(library): extract recording MBID → tracks.mbid; unblock LB similarity
tracks.mbid was 100% NULL: the scanner only extracted album + artist
MBIDs, never the recording MBID. The ListenBrainz similarity worker is
gated on tracks.mbid IS NOT NULL, so track_similarity could never
populate — starving For-You/radio's strongest candidate source
(lb_similar) on every library.

- mbids.go: add extractRecordingMBID (mbz.Recording / Picard
  musicbrainz_recordingid). Separate fn so extractMBIDs' signature +
  unit tests stay untouched.
- scanner.go: persist recording MBID via UpsertTrack (heals on the
  file_path conflict, so re-scans backfill for free).
- BackfillTrackMBIDs: one-shot pass mirroring BackfillMBIDs, wired as
  scan Stage 2b (idempotent via SetTrackMbidIfNull, gated by
  BackfillCap, log-only progress).
- migration 0029: tracks_mbid_unique (0002, written when the column
  was always NULL) wrongly assumes one MBID == one track. A recording
  appears on multiple releases, so rows legitimately share a recording
  MBID. Replace with a non-unique partial index. Zero-risk: column is
  100% NULL at migration time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:49:38 -04:00
bvandeusen 2e7b81fdfe fix(playlists): robust For-You seed + deep fill; young-library mix fallbacks
For-You silently vanished after ~7 days of not listening (seed query
required a non-skip play in the last 7 days) and capped at ~40 on
self-hosted libraries with no ListenBrainz similarity data.

- PickTopPlayedTracksForUser: tiered seed — last 30d top plays, else
  all-time top plays, else liked tracks. For-You only disappears now
  if the account has zero plays AND zero likes.
- produceForYou uses deeper candidate source limits (raised random/
  tag/similar K) so it reaches ~100 even with empty lb_similar /
  similar_artists; richer when LB enrichment is present.
- Rediscover: tiered — 6-month-dormant, else ≥5-play 30-day-dormant.
- On This Day: floor 60→30 days, window ±7→±10 doy; still skips
  cleanly (no rows → no playlist) on insufficient history.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:48:20 -04:00
62 changed files with 1341 additions and 273 deletions
+58 -2
View File
@@ -4,8 +4,16 @@ name: test-go
# dev/main and PRs to main, scoped to Go-side files only — web-only or
# Flutter-only diffs don't trigger this workflow.
#
# Integration tests needing Postgres/ffmpeg run locally via docker-compose;
# they should guard with testing.Short() so this short-mode run skips them.
# Two jobs: `test` (fast — vet + lint + `go test -short -race`, no DB) and
# `integration` (full `go test -race` against an ephemeral Postgres).
#
# Integration-job DB wiring follows the act_runner shared-daemon pattern:
# the runner's Docker daemon also runs the operator's dev compose stack,
# so service containers get NO published ports (collision) and no
# service-name DNS. We discover the service container by the job-scoped
# name filter via the mounted docker socket and reach it by bridge IP.
# The exactly-one assertion is a hard guard — pointing tests at the dev
# Postgres would truncate it (the disaster Fable #339 exists to prevent).
#
# `web/build/` has a committed placeholder index.html so go:embed succeeds
# without needing the SPA to be freshly built. Real builds happen in
@@ -54,3 +62,51 @@ jobs:
- name: go test (short, race)
run: go test -short -race ./...
integration:
runs-on: go-ci
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: minstrel
POSTGRES_PASSWORD: minstrel
POSTGRES_DB: minstrel_test
# No `ports:` — the runner shares the operator's dev compose
# Docker daemon; publishing a fixed host port collides.
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Integration suite (discover service by bridge IP, migrate, test)
run: |
set -eux
# Discover THIS job's Postgres service container via the
# mounted docker socket. Scope by the job-name filter so the
# operator's dev compose `minstrel-postgres-*` (same image,
# same daemon) can never match. Require exactly one — abort
# loudly otherwise; a wrong target would truncate real data.
PG_LIST=$(docker ps --filter "name=integration" --filter "ancestor=postgres:16-alpine" --format '{{.ID}} {{.Names}}')
echo "candidates: ${PG_LIST:-<none>}"
PG_COUNT=$(printf '%s\n' "$PG_LIST" | grep -c . || true)
test "$PG_COUNT" = "1" || { echo "FATAL: expected exactly 1 postgres service container, got $PG_COUNT"; exit 1; }
PG_ID=$(printf '%s' "$PG_LIST" | awk '{print $1}')
PG_NAME=$(printf '%s' "$PG_LIST" | awk '{print $2}')
case "$PG_NAME" in
*minstrel-postgres*|*_postgres_*) echo "FATAL: matched the dev compose container ($PG_NAME), refusing"; exit 1 ;;
esac
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG_ID")
test -n "$PG_IP"
export MINSTREL_TEST_DATABASE_URL="postgres://minstrel:minstrel@${PG_IP}:5432/minstrel_test?sslmode=disable"
# Wait for Postgres to accept TCP (no health-check dependency).
for i in $(seq 1 60); do (echo > "/dev/tcp/${PG_IP}/5432") 2>/dev/null && break; sleep 2; done
# Apply embedded migrations to the fresh test DB, then run the
# full suite (no -short → integration tests execute). -p 1:
# every integration package TRUNCATEs the one shared test DB;
# concurrent package binaries → TRUNCATE deadlocks. Serialize
# package execution (the documented local invocation too).
MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate
go test -p 1 -race ./...
+11 -1
View File
@@ -1,4 +1,4 @@
.PHONY: generate test test-short lint build
.PHONY: generate test test-short test-integration lint build
SQLC_VERSION := 1.31.1
@@ -11,6 +11,16 @@ test:
test-short:
go test -short -race ./...
# Full suite incl. integration tests, against the dedicated minstrel_test
# DB so a run never truncates the dev `minstrel` DB (Fable #339). Ensures
# the test DB exists (idempotent — createdb errors if present, ignored).
test-integration:
docker compose up -d postgres
-docker compose exec -T postgres createdb -U minstrel minstrel_test
# -p 1: integration packages share one test DB and each TRUNCATEs it;
# concurrent package binaries deadlock on TRUNCATE. Serialize packages.
MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable go test -p 1 -race ./...
lint:
golangci-lint run ./...
+10
View File
@@ -83,6 +83,16 @@ Two concurrent dev processes:
1. **Backend:** `docker compose up` — Postgres + Minstrel on `:4533`.
2. **Frontend:** `cd web && npm install && npm run dev` — Vite dev server on `:5173` with HMR. The Vite server proxies `/api/*` and `/rest/*` to `:4533` so session cookies work.
### Testing
- Unit + race (no DB): `make test-short`.
- Full suite incl. integration tests: `make test-integration`. This runs
against a dedicated `minstrel_test` database so a test run never
truncates your dev `minstrel` data (admin user, library, likes). It
brings up the compose Postgres and creates the test DB if missing.
- CI runs both: a fast `go test -short -race` gate plus an integration
job with its own ephemeral Postgres (`.forgejo/workflows/test-go.yml`).
### Production build
`docker build -t minstrel .` runs the SvelteKit build inside a `node` stage, copies the output into the `golang` stage, and `//go:embed`s it into the final binary. The container serves the SPA from `/` alongside the API surfaces; no separate static-file server is required.
+129
View File
@@ -0,0 +1,129 @@
package main
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"flag"
"fmt"
"os"
"golang.org/x/crypto/bcrypt"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
)
// runMigrate applies the embedded migrations and exits. The server also
// auto-migrates on startup (main.go); this standalone path exists for CI
// (provision a fresh DB before the integration suite) and operators who
// want to migrate without starting the server.
func runMigrate(args []string) error {
fs := flag.NewFlagSet("migrate", flag.ContinueOnError)
configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
if err := fs.Parse(args); err != nil {
return err
}
cfg, err := config.Load(*configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
logger, err := logging.New(os.Stdout, cfg.Log.Level, cfg.Log.Format)
if err != nil {
return fmt.Errorf("init logger: %w", err)
}
if err := db.Migrate(cfg.Database.URL, logger); err != nil {
return fmt.Errorf("migrate: %w", err)
}
fmt.Println("minstrel: migrations applied")
return nil
}
// runAdmin dispatches `minstrel admin <subcommand>`.
func runAdmin(args []string) error {
if len(args) == 0 {
return errors.New("usage: minstrel admin reset-password [-user NAME] [-password PW] [-config PATH]")
}
switch args[0] {
case "reset-password":
return adminResetPassword(args[1:])
default:
return fmt.Errorf("unknown admin subcommand %q", args[0])
}
}
// adminResetPassword resets a user's credentials. It updates BOTH
// password_hash (bcrypt, for /api/auth/login) and subsonic_password
// (plaintext, required for Subsonic t+s token verification) so neither
// auth path is left stale. Recovers a locked-out operator when the
// bootstrap password was missed or the DB volume was recreated (Fable
// #321) without DB surgery.
func adminResetPassword(args []string) error {
fs := flag.NewFlagSet("admin reset-password", flag.ContinueOnError)
configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
username := fs.String("user", "admin", "username to reset")
password := fs.String("password", "", "new password; if empty a strong one is generated and printed")
if err := fs.Parse(args); err != nil {
return err
}
cfg, err := config.Load(*configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
if cfg.Database.URL == "" {
return errors.New("no database URL configured (set it in the config file or MINSTREL_DATABASE_URL)")
}
ctx := context.Background()
pool, err := db.Open(ctx, cfg.Database.URL)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer pool.Close()
q := dbq.New(pool)
user, err := q.GetUserByUsername(ctx, *username)
if err != nil {
return fmt.Errorf("look up user %q: %w", *username, err)
}
pw := *password
generated := false
if pw == "" {
b := make([]byte, 18)
if _, err := rand.Read(b); err != nil {
return fmt.Errorf("generate password: %w", err)
}
pw = base64.RawURLEncoding.EncodeToString(b)
generated = true
}
hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
if err := q.ChangeUserPassword(ctx, dbq.ChangeUserPasswordParams{
ID: user.ID,
PasswordHash: string(hash),
}); err != nil {
return fmt.Errorf("update password_hash: %w", err)
}
sp := pw
if err := q.SetSubsonicPassword(ctx, dbq.SetSubsonicPasswordParams{
ID: user.ID,
SubsonicPassword: &sp,
}); err != nil {
return fmt.Errorf("update subsonic_password: %w", err)
}
if generated {
fmt.Printf("minstrel: password for %q reset.\nNew password: %s\n", *username, pw)
} else {
fmt.Printf("minstrel: password for %q reset.\n", *username)
}
return nil
}
+23 -7
View File
@@ -29,6 +29,22 @@ import (
)
func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "admin":
if err := runAdmin(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "minstrel admin: %v\n", err)
os.Exit(1)
}
return
case "migrate":
if err := runMigrate(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "minstrel migrate: %v\n", err)
os.Exit(1)
}
return
}
}
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "minstrel: %v\n", err)
os.Exit(1)
@@ -105,8 +121,8 @@ func run() error {
logger.With("component", "scan_run"),
library.RunScanConfig{
BackfillCap: 5000,
EnrichCap: cfg.Library.CoverArtBackfillCap,
ArtistEnrichCap: cfg.Library.CoverArtBackfillCap,
EnrichCap: -1, // #388: no global cap; per-provider rate limits
ArtistEnrichCap: -1,
DataDir: cfg.Storage.DataDir,
},
); err != nil {
@@ -122,8 +138,8 @@ func run() error {
logger.With("component", "scan_run"),
library.RunScanConfig{
BackfillCap: 5000,
EnrichCap: cfg.Library.CoverArtBackfillCap,
ArtistEnrichCap: cfg.Library.CoverArtBackfillCap,
EnrichCap: -1, // #388: no global cap; per-provider rate limits
ArtistEnrichCap: -1,
DataDir: cfg.Storage.DataDir,
},
); err != nil {
@@ -194,8 +210,8 @@ func run() error {
scanCfg := library.RunScanConfig{
BackfillCap: 5000,
EnrichCap: cfg.Library.CoverArtBackfillCap,
ArtistEnrichCap: cfg.Library.CoverArtBackfillCap,
EnrichCap: -1, // #388: no global cap; per-provider rate limits
ArtistEnrichCap: -1,
DataDir: cfg.Storage.DataDir,
}
scheduler := library.NewScheduler(pool, logger.With("component", "scheduler"),
@@ -203,7 +219,7 @@ func run() error {
scheduler.Start(ctx)
srv := server.New(logger, pool, scanner, subsonic.Config{
AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword,
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, cfg.Library.CoverArtBackfillCap, coverSettings, scanner, scanCfg, scheduler)
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg, scheduler)
srv.Bus = bus
srv.PlaylistScheduler = playlistScheduler
httpServer := &http.Server{
+6
View File
@@ -0,0 +1,6 @@
-- Runs once, only on a fresh Postgres data volume (Docker entrypoint
-- initdb hook). Creates the dedicated integration-test database so
-- `go test` never truncates the operator's dev `minstrel` DB (Fable
-- #339). For an already-initialised volume this script does NOT run;
-- `make test-integration` creates the DB idempotently instead.
CREATE DATABASE minstrel_test OWNER minstrel;
+11 -4
View File
@@ -1,10 +1,14 @@
version: "3.9"
# Local development environment for Minstrel.
# Not used by CI — integration tests run against this compose stack manually:
# docker compose up -d postgres
# MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel?sslmode=disable \
# go test ./...
#
# Integration tests run against a SEPARATE database (minstrel_test) so a
# test run never truncates the dev `minstrel` DB (Fable #339). Use the
# Makefile target, which ensures the test DB exists then points the
# tests at it:
# make test-integration
# (CI runs the same suite against its own ephemeral Postgres service —
# see .forgejo/workflows/test-go.yml.)
#
# Full stack (server + db):
# docker compose up --build
@@ -22,6 +26,9 @@ services:
# - "5432:5432"
volumes:
- minstrel-pgdata:/var/lib/postgresql/data
# Creates minstrel_test on a fresh volume (Fable #339). No-op on
# an existing volume — make test-integration ensures it instead.
- ./deploy/initdb:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U minstrel -d minstrel"]
interval: 5s
@@ -1,11 +1,25 @@
import 'package:dio/dio.dart';
import '../../models/artist_suggestion.dart';
import '../../models/lidarr.dart';
class DiscoverApi {
DiscoverApi(this._dio);
final Dio _dio;
/// GET /api/discover/suggestions — out-of-library artist suggestions
/// (ListenBrainz-derived; image_url resolved on-demand from Lidarr,
/// may be empty). The server already filters in-library and
/// non-terminal-request candidates.
Future<List<ArtistSuggestion>> listSuggestions() async {
final r = await _dio.get<List<dynamic>>('/api/discover/suggestions');
final raw = r.data ?? const [];
return raw
.map((e) =>
ArtistSuggestion.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
/// GET /api/lidarr/search?q=...&kind=artist|album|track. Server has a
/// 60s LRU cache for repeat queries so re-typing the same string in
/// quick succession is cheap.
@@ -8,6 +8,7 @@ import '../api/endpoints/discover.dart';
import '../api/errors.dart';
import '../cache/mutation_queue.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/artist_suggestion.dart';
import '../models/lidarr.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
@@ -27,6 +28,22 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
final _ctrl = TextEditingController();
LidarrRequestKind _kind = LidarrRequestKind.artist;
Future<List<LidarrSearchResult>>? _resultsFuture;
// Default (empty-search) surface: LB-derived out-of-library artist
// suggestions, mirroring web's SuggestionFeed.
Future<List<ArtistSuggestion>>? _suggestionsFuture;
final _requested = <String>{};
@override
void initState() {
super.initState();
_loadSuggestions();
// Clearing the box returns to suggestions (web swaps live too).
_ctrl.addListener(() {
if (_ctrl.text.trim().isEmpty && _resultsFuture != null) {
setState(() => _resultsFuture = null);
}
});
}
@override
void dispose() {
@@ -34,6 +51,55 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
super.dispose();
}
// Assigns the future only; callers trigger the rebuild (initState
// runs before first build, so setState here would be a no-op/warn).
void _loadSuggestions() {
_suggestionsFuture = ref
.read(_discoverApiProvider.future)
.then((api) => api.listSuggestions());
}
Future<void> _requestSuggestion(ArtistSuggestion s) async {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final args = {
'kind': LidarrRequestKind.artist.wire,
'artistMbid': s.mbid,
'artistName': s.name,
'albumMbid': null,
'albumTitle': null,
};
try {
final api = await ref.read(_discoverApiProvider.future);
await api.createRequest(
kind: LidarrRequestKind.artist,
artistMbid: s.mbid,
artistName: s.name,
);
if (mounted) {
// Reassign the future first; the setState below rebuilds and
// the FutureBuilder picks up the fresh fetch (server now
// filters this candidate out). _requested hides it meanwhile.
_loadSuggestions();
setState(() => _requested.add(s.mbid));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Requested: ${s.name}'),
backgroundColor: fs.iron,
));
}
} on DioException catch (_) {
await ref
.read(mutationQueueProvider)
.enqueue(MutationKinds.requestCreate, args);
if (mounted) {
setState(() => _requested.add(s.mbid));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Request queued: ${s.name}'),
backgroundColor: fs.iron,
));
}
}
}
void _runSearch() {
final q = _ctrl.text.trim();
if (q.isEmpty) {
@@ -153,13 +219,7 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
),
Expanded(
child: _resultsFuture == null
? Center(
child: Text(
'Type to search, then tap Request to send to Lidarr.',
textAlign: TextAlign.center,
style: TextStyle(color: fs.ash),
),
)
? _buildSuggestions(fs)
: FutureBuilder<List<LidarrSearchResult>>(
future: _resultsFuture,
builder: (ctx, snap) {
@@ -199,6 +259,62 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
]),
);
}
Widget _buildSuggestions(FabledSwordTheme fs) {
return FutureBuilder<List<ArtistSuggestion>>(
future: _suggestionsFuture,
builder: (ctx, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
final items = (snap.data ?? const <ArtistSuggestion>[])
.where((s) => !_requested.contains(s.mbid))
.toList(growable: false);
return ListView(
padding: const EdgeInsets.only(bottom: 16),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Suggested for you',
style: TextStyle(
color: fs.parchment,
fontSize: 20,
fontWeight: FontWeight.w500)),
const SizedBox(height: 2),
Text(
"Out-of-library artists drawn from what you've liked and played.",
style: TextStyle(color: fs.ash, fontSize: 13),
),
],
),
),
if (snap.hasError)
Padding(
padding: const EdgeInsets.all(16),
child: Text("Couldn't load suggestions.",
style: TextStyle(color: fs.ash)),
)
else if (items.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Listen to something or like an artist to start getting suggestions.',
style: TextStyle(color: fs.ash),
),
)
else
...items.map((s) => _SuggestionTile(
s: s,
onRequest: () => _requestSuggestion(s),
)),
],
);
},
);
}
}
class _ResultTile extends StatelessWidget {
@@ -284,3 +400,63 @@ class _Pill extends StatelessWidget {
);
}
}
class _SuggestionTile extends StatelessWidget {
const _SuggestionTile({required this.s, required this.onRequest});
final ArtistSuggestion s;
final VoidCallback onRequest;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Container(
width: 56,
height: 56,
color: fs.slate,
child: s.imageUrl.isEmpty
? Icon(Icons.person, color: fs.ash)
: CachedNetworkImage(
imageUrl: s.imageUrl,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 120),
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) =>
Icon(Icons.person, color: fs.ash),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(s.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.parchment, fontSize: 14)),
if (s.attributionText.isNotEmpty)
Text(s.attributionText,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: fs.ash, fontSize: 12)),
],
),
),
const SizedBox(width: 8),
FilledButton(
onPressed: onRequest,
style: FilledButton.styleFrom(
backgroundColor: fs.accent,
foregroundColor: fs.parchment,
),
child: const Text('Request'),
),
]),
);
}
}
@@ -0,0 +1,51 @@
/// Mirrors web/src/lib/api/types.ts ArtistSuggestion / SeedContribution —
/// one out-of-library artist from GET /api/discover/suggestions. image_url
/// is resolved on-demand from Lidarr server-side (may be empty).
class SeedContribution {
const SeedContribution({required this.name, required this.isLiked});
final String name;
final bool isLiked;
factory SeedContribution.fromJson(Map<String, dynamic> j) => SeedContribution(
name: j['name'] as String? ?? '',
isLiked: j['is_liked'] as bool? ?? false,
);
}
class ArtistSuggestion {
const ArtistSuggestion({
required this.mbid,
required this.name,
required this.imageUrl,
required this.attribution,
});
final String mbid;
final String name;
final String imageUrl;
final List<SeedContribution> attribution;
factory ArtistSuggestion.fromJson(Map<String, dynamic> j) => ArtistSuggestion(
mbid: j['mbid'] as String? ?? '',
name: j['name'] as String? ?? '',
imageUrl: j['image_url'] as String? ?? '',
attribution: ((j['attribution'] as List?) ?? const [])
.map((e) =>
SeedContribution.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false),
);
/// Mirrors web SuggestionFeed.attributionText (Oxford comma, max 3).
String get attributionText {
if (attribution.isEmpty) return '';
final phrases = attribution
.map((s) => '${s.isLiked ? 'liked' : 'played'} ${s.name}')
.toList(growable: false);
if (phrases.length == 1) return 'Because you ${phrases[0]}.';
if (phrases.length == 2) {
return 'Because you ${phrases[0]} and ${phrases[1]}.';
}
return 'Because you ${phrases[0]}, ${phrases[1]}, and ${phrases[2]}.';
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: minstrel
description: Minstrel mobile client
publish_to: 'none'
version: 2026.5.15+7
version: 2026.5.18+9
environment:
sdk: '>=3.5.0 <4.0.0'
+1 -1
View File
@@ -27,7 +27,7 @@ func (p *apiTestAlbumProvider) DisplayName() string { re
func (p *apiTestAlbumProvider) RequiresAPIKey() bool { return false }
func (p *apiTestAlbumProvider) DefaultEnabled() bool { return true }
func (p *apiTestAlbumProvider) Configure(_ coverart.ProviderSettings) error { return nil }
func (p *apiTestAlbumProvider) FetchAlbumCover(_ context.Context, _ string) ([]byte, error) {
func (p *apiTestAlbumProvider) FetchAlbumCover(_ context.Context, _ coverart.AlbumRef) ([]byte, error) {
return []byte("img"), nil
}
+7 -6
View File
@@ -48,19 +48,20 @@ func (h *handlers) handleAdminAlbumRefetchCover(w http.ResponseWriter, r *http.R
}
type adminBulkRefetchResp struct {
Queued int `json:"queued"`
Started bool `json:"started"`
}
// handleAdminBulkRefetchCovers implements POST /api/admin/covers/refetch-missing.
// Returns immediately; actual drain runs in a background goroutine bounded by
// the configured backfill cap.
// Returns immediately; the drain runs UNBOUNDED in a background goroutine
// (#388 — no global cap; remote providers self-throttle per provider, local
// sources run at disk speed). The count is unknowable synchronously, so the
// response just acknowledges the drain started.
func (h *handlers) handleAdminBulkRefetchCovers(w http.ResponseWriter, _ *http.Request) {
cap := h.coverArtBackfillCap
go func() {
bgCtx := context.Background()
if _, err := h.coverart.EnrichRetryMissing(bgCtx, cap); err != nil {
if _, err := h.coverart.EnrichRetryMissing(bgCtx, -1); err != nil {
h.logger.Warn("admin: bulk cover refetch failed", "err", err)
}
}()
writeJSON(w, http.StatusOK, adminBulkRefetchResp{Queued: cap})
writeJSON(w, http.StatusOK, adminBulkRefetchResp{Started: true})
}
+3 -4
View File
@@ -53,7 +53,6 @@ func testHandlersWithCovers(t *testing.T) (*handlers, *coverart.Enricher) {
}
enricher := coverart.NewEnricher(pool, slog.Default(), settings)
h.coverart = enricher
h.coverArtBackfillCap = 100
return h, enricher
}
@@ -97,7 +96,7 @@ func TestAdminAlbumRefetchCover_AdminOK(t *testing.T) {
}
}
func TestAdminBulkRefetchCovers_AdminReturnsQueuedCount(t *testing.T) {
func TestAdminBulkRefetchCovers_AdminStartsRefetch(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
@@ -115,8 +114,8 @@ func TestAdminBulkRefetchCovers_AdminReturnsQueuedCount(t *testing.T) {
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Queued != h.coverArtBackfillCap {
t.Errorf("queued = %d, want %d (cap)", resp.Queued, h.coverArtBackfillCap)
if !resp.Started {
t.Errorf("started = false, want true (bulk refetch should acknowledge)")
}
}
+4 -3
View File
@@ -137,8 +137,9 @@ func TestHandlePutLidarrConfig_EmptyKeyPreservesSaved(t *testing.T) {
APIKey: "originalkey",
})
// PUT with empty api_key — should preserve "originalkey".
body := []byte(`{"enabled":true,"base_url":"http://lidarr.lan:8686","api_key":""}`)
// PUT with empty api_key — should preserve "originalkey". enabled=true
// requires the defaults gate (missing_defaults) to be satisfied.
body := []byte(`{"enabled":true,"base_url":"http://lidarr.lan:8686","api_key":"","default_quality_profile_id":1,"default_metadata_profile_id":1,"default_root_folder_path":"/music"}`)
w := doAdminReq(t, h, http.MethodPut, "/api/admin/lidarr/config", body, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
@@ -183,7 +184,7 @@ func TestHandlePutLidarrConfig_HappyPath(t *testing.T) {
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
body := []byte(`{"enabled":true,"base_url":"http://lidarr.lan:8686","api_key":"newkey","default_quality_profile_id":2,"default_root_folder_path":"/music"}`)
body := []byte(`{"enabled":true,"base_url":"http://lidarr.lan:8686","api_key":"newkey","default_quality_profile_id":2,"default_metadata_profile_id":1,"default_root_folder_path":"/music"}`)
w := doAdminReq(t, h, http.MethodPut, "/api/admin/lidarr/config", body, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
+18 -3
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
@@ -174,10 +175,17 @@ func TestHandleApproveRequest_HappyPath(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":1}`))
switch {
case strings.Contains(r.URL.Path, "/metadataprofile"):
_, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
case strings.Contains(r.URL.Path, "/qualityprofile"):
_, _ = w.Write([]byte(`[{"id":1,"name":"Lossless"}]`))
default:
_, _ = w.Write([]byte(`{"id":1}`))
}
}))
t.Cleanup(stub.Close)
@@ -221,7 +229,14 @@ func TestHandleApproveRequest_OverrideUsed(t *testing.T) {
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":1}`))
switch {
case strings.Contains(r.URL.Path, "/metadataprofile"):
_, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
case strings.Contains(r.URL.Path, "/qualityprofile"):
_, _ = w.Write([]byte(`[{"id":1,"name":"Lossless"}]`))
default:
_, _ = w.Write([]byte(`{"id":1}`))
}
}))
t.Cleanup(stub.Close)
+4 -1
View File
@@ -163,7 +163,10 @@ func TestAdminCreateUser_DuplicateUsername_409(t *testing.T) {
admin := seedUser(t, pool, "duper", "pw", true)
seedUser(t, pool, "existing", "pw", false)
body := `{"username":"existing","password":"abcd1234"}`
// seedUser prefixes usernames with dbtest.TestUserPrefix, so the
// row above is "test-existing"; POST that exact name to actually
// collide (stays test-prefixed for ResetDB).
body := `{"username":"test-existing","password":"abcd1234"}`
req := httptest.NewRequest(http.MethodPost, "/api/admin/users", bytes.NewReader([]byte(body)))
req = withUser(req, admin)
rec := httptest.NewRecorder()
+35 -37
View File
@@ -28,26 +28,25 @@ import (
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware. The events writer
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverBackfillCap int, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler) {
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler) {
rng := rand.New(rand.NewSource(rand.Int63()))
h := &handlers{
pool: pool, logger: logger, events: events, recCfg: recCfg,
rng: rng.Float64,
lidarrCfg: lidarrCfg,
lidarrRequests: lidarrReqs,
lidarrQuarantine: lidarrQuar,
tracks: tracksSvc,
playlists: playlistsSvc,
coverart: coverEnricher,
coverArtBackfillCap: coverBackfillCap,
coverSettings: coverSettings,
scanner: scanner,
scanCfg: scanCfg,
scheduler: scheduler,
dataDir: dataDir,
mailer: sender,
eventbus: bus,
playlistScheduler: playlistScheduler,
rng: rng.Float64,
lidarrCfg: lidarrCfg,
lidarrRequests: lidarrReqs,
lidarrQuarantine: lidarrQuar,
tracks: tracksSvc,
playlists: playlistsSvc,
coverart: coverEnricher,
coverSettings: coverSettings,
scanner: scanner,
scanCfg: scanCfg,
scheduler: scheduler,
dataDir: dataDir,
mailer: sender,
eventbus: bus,
playlistScheduler: playlistScheduler,
}
r.Route("/api", func(api chi.Router) {
@@ -180,24 +179,23 @@ 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
rng func() float64
lidarrCfg *lidarrconfig.Service
lidarrRequests *lidarrrequests.Service
lidarrQuarantine *lidarrquarantine.Service
tracks *tracks.Service
playlists *playlists.Service
coverart *coverart.Enricher
coverArtBackfillCap int
coverSettings *coverart.SettingsService
scanner *library.Scanner
scanCfg library.RunScanConfig
scheduler *library.Scheduler
dataDir string
mailer mailer.Sender
eventbus *eventbus.Bus
playlistScheduler *playlists.Scheduler
pool *pgxpool.Pool
logger *slog.Logger
events *playevents.Writer
recCfg config.RecommendationConfig
rng func() float64
lidarrCfg *lidarrconfig.Service
lidarrRequests *lidarrrequests.Service
lidarrQuarantine *lidarrquarantine.Service
tracks *tracks.Service
playlists *playlists.Service
coverart *coverart.Enricher
coverSettings *coverart.SettingsService
scanner *library.Scanner
scanCfg library.RunScanConfig
scheduler *library.Scheduler
dataDir string
mailer mailer.Sender
eventbus *eventbus.Bus
playlistScheduler *playlists.Scheduler
}
+1 -1
View File
@@ -444,7 +444,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
r := chi.NewRouter()
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
30*time.Minute, 0.5, 30000)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverArtBackfillCap, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil, eventbus.New(), nil)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil, eventbus.New(), nil)
paths := []string{
"/api/artists",
+7 -3
View File
@@ -69,14 +69,18 @@ func TestPutTimezone_InvalidIANA(t *testing.T) {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
// Error envelope is nested: {"error":{"code":...}} (matches the
// rest of /api/*), not a top-level {"code":...}.
var errResp struct {
Code string `json:"code"`
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.NewDecoder(rec.Body).Decode(&errResp); err != nil {
t.Fatalf("decode err response: %v", err)
}
if errResp.Code != "invalid_timezone" {
t.Errorf("error code = %q, want invalid_timezone", errResp.Code)
if errResp.Error.Code != "invalid_timezone" {
t.Errorf("error code = %q, want invalid_timezone", errResp.Error.Code)
}
}
+5 -3
View File
@@ -8,8 +8,6 @@ import (
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
)
// Generic registry-driven system-playlist endpoints (#411 R2).
@@ -17,7 +15,11 @@ import (
func newSystemPlaylistRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Route("/api", func(api chi.Router) {
api.Use(auth.RequireUser(h.pool))
// No real auth.RequireUser middleware: like every other api
// handler test, auth is supplied via withUser() context and the
// handlers self-guard with requireUser() (prelude.go). The real
// middleware needs a live session and would 401 the injected
// test user.
api.Post("/playlists/system/{kind}/refresh", h.handleSystemPlaylistRefresh)
api.Get("/playlists/system/{kind}/shuffle", h.handleSystemPlaylistShuffle)
})
+1 -1
View File
@@ -19,7 +19,7 @@ import (
func requireUser(w http.ResponseWriter, r *http.Request) (dbq.User, bool) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeErr(w, apierror.Unauthorized("auth_required", ""))
writeErr(w, apierror.Unauthorized("unauthenticated", ""))
return dbq.User{}, false
}
return user, true
+55
View File
@@ -1,12 +1,15 @@
package api
import (
"context"
"net/http"
"strconv"
"sync"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
)
@@ -16,6 +19,11 @@ type suggestionView struct {
Name string `json:"name"`
Score float64 `json:"score"`
Attribution []seedContributionView `json:"attribution"`
// ImageURL is resolved on-demand from Lidarr (out-of-library
// artists have no local art row). Omitted when Lidarr is disabled
// or has no match — the client falls back to a placeholder. Not
// cached: a remote URL Lidarr surfaced, fetched by the browser.
ImageURL string `json:"image_url,omitempty"`
}
type seedContributionView struct {
@@ -80,5 +88,52 @@ func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request)
MBID: s.MBID, Name: s.Name, Score: s.Score, Attribution: attr,
})
}
h.resolveSuggestionArt(r.Context(), out)
writeJSON(w, http.StatusOK, out)
}
// resolveSuggestionArt fills ImageURL on-demand from Lidarr's artist
// lookup, matched by MBID (foreignArtistId). Best-effort and cache-free:
// Lidarr is the only source — when it's disabled, unreachable, or has
// no match for a candidate, that entry keeps an empty ImageURL and the
// client renders its placeholder. Lookups run with bounded concurrency
// so a full Discover page doesn't serialize ~12 round-trips. Never
// fails the request; the suggestions list is the contract, art is a
// nicety.
func (h *handlers) resolveSuggestionArt(ctx context.Context, views []suggestionView) {
if len(views) == 0 {
return
}
cfg, err := h.lidarrCfg.Get(ctx)
if err != nil || !cfg.Enabled || cfg.BaseURL == "" || cfg.APIKey == "" {
return // Lidarr off / unconfigured → placeholders only
}
client := lidarr.NewClient(cfg.BaseURL, cfg.APIKey)
const maxConcurrent = 6
sem := make(chan struct{}, maxConcurrent)
var wg sync.WaitGroup
for i := range views {
if views[i].MBID == "" || views[i].Name == "" {
continue
}
wg.Add(1)
sem <- struct{}{}
go func(idx int) {
defer wg.Done()
defer func() { <-sem }()
results, lerr := client.LookupArtist(ctx, views[idx].Name)
if lerr != nil {
return // best-effort: no art on lookup failure
}
for _, res := range results {
if res.MBID == views[idx].MBID && res.ImageURL != "" {
// Distinct slice index per goroutine → race-free.
views[idx].ImageURL = res.ImageURL
return
}
}
}(i)
}
wg.Wait()
}
+3 -1
View File
@@ -65,7 +65,9 @@ func TestWrite_WithMetadata(t *testing.T) {
).Scan(&meta); err != nil {
t.Fatalf("read metadata: %v", err)
}
if !contains(meta, `"first_admin":true`) || !contains(meta, `"reason":"test"`) {
// Postgres jsonb::text renders a space after ':' and ',', e.g.
// {"reason": "test", "first_admin": true}.
if !contains(meta, `"first_admin": true`) || !contains(meta, `"reason": "test"`) {
t.Errorf("metadata = %q, expected first_admin + reason fields", meta)
}
}
+5 -12
View File
@@ -56,11 +56,10 @@ type LogConfig struct {
}
type LibraryConfig struct {
ScanPaths []string `yaml:"scan_paths"`
ScanOnStartup bool `yaml:"scan_on_startup"`
CoverArtFromMBCAA bool `yaml:"coverart_from_mbcaa"` // default true
CoverArtBackfillCap int `yaml:"coverart_backfill_cap"` // default 500
ContactEmail string `yaml:"contact_email"` // optional override for MBCAA UA
ScanPaths []string `yaml:"scan_paths"`
ScanOnStartup bool `yaml:"scan_on_startup"`
CoverArtFromMBCAA bool `yaml:"coverart_from_mbcaa"` // default true
ContactEmail string `yaml:"contact_email"` // optional override for MBCAA UA
}
// SubsonicConfig controls wire-format concessions on the /rest/* surface.
@@ -120,8 +119,7 @@ func Default() Config {
RadioSizeMax: 200,
},
Library: LibraryConfig{
CoverArtFromMBCAA: true,
CoverArtBackfillCap: 500,
CoverArtFromMBCAA: true,
},
}
}
@@ -177,11 +175,6 @@ func applyEnv(cfg *Config) {
cfg.Library.CoverArtFromMBCAA = b
}
}
if v, ok := os.LookupEnv("MINSTREL_LIBRARY_COVERART_BACKFILL_CAP"); ok {
if n, err := strconv.Atoi(v); err == nil {
cfg.Library.CoverArtBackfillCap = n
}
}
if v, ok := os.LookupEnv("MINSTREL_CONTACT_EMAIL"); ok {
cfg.Library.ContactEmail = v
}
+8 -2
View File
@@ -144,15 +144,21 @@ func (e *Enricher) recordArtistArt(ctx context.Context, artistID pgtype.UUID, th
// errored). The JSON tally written to scan_runs keeps the existing
// processed/succeeded/failed shape for backwards compat with the
// admin overview UI; the log line is the operator's diagnostic.
// limit: 0 = stage disabled; >0 = bounded; <0 = unbounded (#388 —
// no global cap; remote providers self-throttle per provider).
func (e *Enricher) EnrichArtistBatch(ctx context.Context, limit int, dataDir string,
progressCb func(processed, succeeded, failed int)) (processed, succeeded, failed int, err error) {
if limit <= 0 {
if limit == 0 {
return 0, 0, 0, nil
}
queryLimit := int32(limit)
if limit < 0 {
queryLimit = 1<<31 - 1 // unbounded
}
q := dbq.New(e.pool)
rows, qerr := q.ListArtistsMissingArt(ctx, dbq.ListArtistsMissingArtParams{
ArtistArtSourcesVersion: e.settings.CurrentVersion(),
Limit: int32(limit),
Limit: queryLimit,
})
if qerr != nil {
return 0, 0, 0, fmt.Errorf("list missing artist art: %w", qerr)
+8 -4
View File
@@ -496,7 +496,7 @@ func TestCleanupArtistArt_IdempotentMissingDir(t *testing.T) {
// --- MBID guard ---
func TestEnrichArtist_NoMBID_LeavesNull(t *testing.T) {
func TestEnrichArtist_NoMBID_SettlesNone(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
q := dbq.New(pool)
@@ -504,9 +504,13 @@ func TestEnrichArtist_NoMBID_LeavesNull(t *testing.T) {
resetRegistryForTests()
t.Cleanup(resetRegistryForTests)
// No MBID still runs the provider chain (name-based providers can
// resolve without one). An MBID-only provider returns ErrNotFound
// for an empty MBID; with the whole chain returning ErrNotFound the
// row settles 'none' (version-stamped), NOT NULL.
stub := &stubArtistProvider{
fakeProvider: fakeProvider{id: "stub-artist", defaultOn: true},
thumb: []byte("should_not_write"),
err: ErrNotFound,
}
Register(stub)
@@ -523,7 +527,7 @@ func TestEnrichArtist_NoMBID_LeavesNull(t *testing.T) {
if err != nil {
t.Fatalf("GetArtistByID: %v", err)
}
if row.ArtistArtSource != nil {
t.Errorf("source = %v, want nil (no MBID — skip)", row.ArtistArtSource)
if row.ArtistArtSource == nil || *row.ArtistArtSource != "none" {
t.Errorf("source = %v, want 'none' (settled)", row.ArtistArtSource)
}
}
+18 -4
View File
@@ -213,15 +213,23 @@ func (e *Enricher) recordAlbumCover(ctx context.Context, albumID pgtype.UUID, pa
// JSON tally written to scan_runs keeps the existing
// processed/succeeded/failed shape for backwards compat with the
// admin overview UI; the log line is the operator's diagnostic.
// limit semantics: 0 = stage disabled (skip); >0 = bounded batch;
// <0 = unbounded — walk every album needing art. The global cap was
// removed (#388); external providers self-throttle via their
// per-provider httpClient MinInterval, local sources run at disk speed.
func (e *Enricher) EnrichBatch(ctx context.Context, limit int,
progressCb func(processed, succeeded, failed int)) (processed, succeeded, failed int, err error) {
if limit <= 0 {
if limit == 0 {
return 0, 0, 0, nil
}
queryLimit := int32(limit)
if limit < 0 {
queryLimit = 1<<31 - 1 // unbounded
}
q := dbq.New(e.pool)
rows, qerr := q.ListAlbumsMissingCover(ctx, dbq.ListAlbumsMissingCoverParams{
CoverArtSourcesVersion: e.settings.CurrentVersion(),
Limit: int32(limit),
Limit: queryLimit,
})
if qerr != nil {
return 0, 0, 0, fmt.Errorf("list missing: %w", qerr)
@@ -293,12 +301,18 @@ func (e *Enricher) logBatchSummary(stage string, eligible, processed, succeeded,
// EnrichRetryMissing drains both NULL and 'none' rows. Used by the
// admin bulk-retry endpoint. Clears 'none' to NULL first so
// EnrichAlbum picks them up.
// limit: 0 = no-op; >0 = bounded; <0 = unbounded (retry every
// missing/none album). Admin bulk-retry passes <0 post-#388.
func (e *Enricher) EnrichRetryMissing(ctx context.Context, limit int) (int, error) {
if limit <= 0 {
if limit == 0 {
return 0, nil
}
queryLimit := int32(limit)
if limit < 0 {
queryLimit = 1<<31 - 1 // unbounded
}
q := dbq.New(e.pool)
rows, err := q.ListAlbumsRetryMissing(ctx, int32(limit))
rows, err := q.ListAlbumsRetryMissing(ctx, queryLimit)
if err != nil {
return 0, fmt.Errorf("list retry missing: %w", err)
}
+33 -10
View File
@@ -49,8 +49,11 @@ func discardLogger() *slog.Logger {
// pick them up.
func newTestEnricher(t *testing.T, pool *pgxpool.Pool) *Enricher {
t.Helper()
resetRegistryForTests()
t.Cleanup(resetRegistryForTests)
// Do NOT reset the registry here: callers register their fakes
// before calling this (see doc above) and resetting would wipe them
// so reconcile() sees zero providers — every art source then stays
// NULL. Registry lifecycle is the caller's (each test does
// resetRegistryForTests + t.Cleanup before Register()).
s, err := NewSettingsService(context.Background(), pool, discardLogger())
if err != nil {
t.Fatalf("NewSettingsService: %v", err)
@@ -100,6 +103,8 @@ func TestEnrichAlbum_SidecarFound(t *testing.T) {
t.Fatal(err)
}
resetRegistryForTests() // deterministic empty registry (sidecar-only path)
t.Cleanup(resetRegistryForTests)
e := newTestEnricher(t, pool)
if err := e.EnrichAlbum(context.Background(), id); err != nil {
t.Fatalf("enrich: %v", err)
@@ -116,9 +121,11 @@ func TestEnrichAlbum_SidecarFound(t *testing.T) {
}
}
func TestEnrichAlbum_NoSidecarNoMBID_LeavesNull(t *testing.T) {
func TestEnrichAlbum_NoSidecarNoMBID_SettlesNone(t *testing.T) {
pool := newPool(t)
id, _ := seedAlbumWithTrack(t, pool, "NoMbid", "Artist", "")
resetRegistryForTests() // deterministic empty registry (no provider can supply)
t.Cleanup(resetRegistryForTests)
e := newTestEnricher(t, pool)
if err := e.EnrichAlbum(context.Background(), id); err != nil {
t.Fatalf("enrich: %v", err)
@@ -127,8 +134,11 @@ func TestEnrichAlbum_NoSidecarNoMBID_LeavesNull(t *testing.T) {
if err != nil {
t.Fatalf("get: %v", err)
}
if row.CoverArtSource != nil {
t.Errorf("source = %v, want nil (NULL — eligible for retry on next scan)", row.CoverArtSource)
// No sidecar, no MBID, no provider yields → the chain settles
// 'none' (allWere404 stays true), version-stamped so it is only
// re-tried when the registered provider set changes.
if row.CoverArtSource == nil || *row.CoverArtSource != "none" {
t.Errorf("source = %v, want 'none' (settled)", row.CoverArtSource)
}
}
@@ -227,6 +237,8 @@ func TestEnrichAlbum_AlreadySidecar_NoOp(t *testing.T) {
t.Fatal(err)
}
resetRegistryForTests() // deterministic empty registry (sidecar-only path)
t.Cleanup(resetRegistryForTests)
e := newTestEnricher(t, pool)
if err := e.EnrichAlbum(context.Background(), id); err != nil {
t.Fatalf("first enrich: %v", err)
@@ -253,16 +265,27 @@ func TestEnrichBatch_DrainsNullSourceOnly(t *testing.T) {
}
id2, _ := seedAlbumWithTrack(t, pool, "Drain2", "B", "")
// Pre-mark id2 as 'none' with current version — should NOT be drained by EnrichBatch
// (ListAlbumsMissingCover only returns stale-version 'none').
resetRegistryForTests() // deterministic empty registry
t.Cleanup(resetRegistryForTests)
e := newTestEnricher(t, pool)
// Pre-mark id2 as 'none' AT the current sources version so
// ListAlbumsMissingCover excludes it (it only returns NULL or
// stale-version 'none'). SetAlbumCover does NOT stamp the version;
// SetAlbumCoverWithVersion does — use the live current version, the
// same value EnrichBatch compares against.
curVer, err := dbq.New(pool).GetCurrentSourcesVersion(context.Background())
if err != nil {
t.Fatalf("current version: %v", err)
}
src := "none"
if err := dbq.New(pool).SetAlbumCover(context.Background(), dbq.SetAlbumCoverParams{
ID: id2, Column2: "", CoverArtSource: &src,
if err := dbq.New(pool).SetAlbumCoverWithVersion(context.Background(), dbq.SetAlbumCoverWithVersionParams{
ID: id2, CoverArtPath: nil, CoverArtSource: &src, CoverArtSourcesVersion: curVer,
}); err != nil {
t.Fatal(err)
}
e := newTestEnricher(t, pool)
processed, _, _, err := e.EnrichBatch(context.Background(), 100, nil)
if err != nil {
t.Fatalf("batch: %v", err)
+47 -20
View File
@@ -202,8 +202,8 @@ WITH windowed AS (
SELECT track_id, COUNT(*) AS c
FROM play_events
WHERE user_id = $1 AND was_skipped = false
AND started_at < now() - interval '60 days'
AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 7
AND started_at < now() - interval '30 days'
AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 10
GROUP BY track_id
)
SELECT t.id, t.album_id, t.artist_id
@@ -229,9 +229,12 @@ type ListOnThisDayTracksRow struct {
}
// #422 On This Day: tracks the user played around this calendar date
// (±7 day-of-year) in the past, excluding the very recent (>60 days
// (±10 day-of-year) in the past, excluding the very recent (>30 days
// ago) so it's nostalgic, not just "last week". Weighted by how much
// they were played in those windows.
// they were played in those windows. Floor relaxed from 60→30 days
// and window ±7→±10 so it surfaces on a months-old library instead
// of needing a full year of history; still skips cleanly (no rows →
// no playlist) when there's no qualifying history yet.
// $1 user_id, $2 date string.
func (q *Queries) ListOnThisDayTracks(ctx context.Context, arg ListOnThisDayTracksParams) ([]ListOnThisDayTracksRow, error) {
rows, err := q.db.Query(ctx, listOnThisDayTracks, arg.UserID, arg.Column2)
@@ -255,21 +258,41 @@ func (q *Queries) ListOnThisDayTracks(ctx context.Context, arg ListOnThisDayTrac
const listRediscoverTracks = `-- name: ListRediscoverTracks :many
WITH stats AS (
SELECT track_id, COUNT(*) AS c, MAX(started_at) AS last_at
FROM play_events
WHERE user_id = $1 AND was_skipped = false
GROUP BY track_id
SELECT pe.track_id, COUNT(*) AS c, MAX(pe.started_at) AS last_at
FROM play_events pe
WHERE pe.user_id = $1 AND pe.was_skipped = false
GROUP BY pe.track_id
),
deep AS (
SELECT t.id, t.album_id, t.artist_id, s.c, 0 AS tier
FROM tracks t
JOIN stats s ON s.track_id = t.id
WHERE s.c >= 5
AND s.last_at <= now() - interval '6 months'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
),
shallow AS (
SELECT t.id, t.album_id, t.artist_id, s.c, 1 AS tier
FROM tracks t
JOIN stats s ON s.track_id = t.id
WHERE s.c >= 5
AND s.last_at <= now() - interval '30 days'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
)
SELECT t.id, t.album_id, t.artist_id
FROM tracks t
JOIN stats s ON s.track_id = t.id
WHERE s.c >= 5
AND s.last_at <= now() - interval '6 months'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY s.c DESC, t.id
SELECT id, album_id, artist_id
FROM (
SELECT id, album_id, artist_id, c, tier FROM deep
UNION ALL
SELECT id, album_id, artist_id, c, tier FROM shallow
WHERE NOT EXISTS (SELECT 1 FROM deep)
) u
ORDER BY tier, c DESC, id
LIMIT 200
`
@@ -280,8 +303,12 @@ type ListRediscoverTracksRow struct {
}
// #420 Rediscover: tracks the user played a lot (>=5 non-skip) but
// not in the last 6 months. Ordered by historical affection.
// $1 user_id.
// has drifted away from. Tiered so a young library still gets a mix:
//
// tier 0 not played in the last 6 months (true rediscovery)
// tier 1 (only if tier 0 empty) not played in the last 30 days
//
// Ordered by historical affection. $1 user_id.
func (q *Queries) ListRediscoverTracks(ctx context.Context, userID pgtype.UUID) ([]ListRediscoverTracksRow, error) {
rows, err := q.db.Query(ctx, listRediscoverTracks, userID)
if err != nil {
+46 -14
View File
@@ -412,23 +412,55 @@ func (q *Queries) PickTopPlayedTrackForArtistByUser(ctx context.Context, arg Pic
}
const pickTopPlayedTracksForUser = `-- name: PickTopPlayedTracksForUser :many
SELECT t.id
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
AND pe.started_at > now() - INTERVAL '7 days'
AND pe.was_skipped = false
GROUP BY t.id
ORDER BY COUNT(*) DESC, t.id
WITH recent AS (
SELECT t.id, COUNT(*) AS c, 0 AS tier
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
AND pe.started_at > now() - INTERVAL '30 days'
AND pe.was_skipped = false
GROUP BY t.id
),
alltime AS (
SELECT t.id, COUNT(*) AS c, 1 AS tier
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
AND pe.was_skipped = false
GROUP BY t.id
),
liked AS (
SELECT gl.track_id AS id, 0::bigint AS c, 2 AS tier
FROM general_likes gl
WHERE gl.user_id = $1
),
chosen AS (
SELECT id, c, tier FROM recent
UNION ALL
SELECT id, c, tier FROM alltime
WHERE NOT EXISTS (SELECT 1 FROM recent)
UNION ALL
SELECT id, c, tier FROM liked
WHERE NOT EXISTS (SELECT 1 FROM recent)
AND NOT EXISTS (SELECT 1 FROM alltime)
)
SELECT id
FROM chosen
ORDER BY tier, c DESC, id
LIMIT 5
`
// For-You candidate seeds. Returns the user's top-5 most-played
// non-skipped tracks in the last 7 days; tie-break by track_id for
// determinism. The Go-side picker (pickForYouSeedForDay) chooses one
// of the returned rows as today's seed via userIDHash so the
// candidate pool rotates day-to-day while staying stable within a
// day.
// For-You candidate seeds, tiered so For-You never silently vanishes:
//
// tier 0 top non-skip plays in the last 30 days
// tier 1 (only if tier 0 empty) all-time top non-skip plays
// tier 2 (only if tiers 0+1 empty) liked tracks
//
// Returns up to 5 ids; tie-break by track_id for determinism. The
// Go-side picker (pickForYouSeedForDay) rotates one per day via
// userIDHash. Widened from a hard 7-day window, which made For-You
// disappear after a week of not listening and never recover on a
// self-hosted library with sparse history.
func (q *Queries) PickTopPlayedTracksForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, pickTopPlayedTracksForUser, userID)
if err != nil {
+53
View File
@@ -376,6 +376,41 @@ func (q *Queries) ListTracksByAlbum(ctx context.Context, arg ListTracksByAlbumPa
return items, nil
}
const listTracksMissingMbidWithPath = `-- name: ListTracksMissingMbidWithPath :many
SELECT id, file_path
FROM tracks
WHERE mbid IS NULL
ORDER BY id
LIMIT $1
`
type ListTracksMissingMbidWithPathRow struct {
ID pgtype.UUID
FilePath string
}
// Track recording-MBID backfill: tracks with NULL mbid that still have
// a file to re-read. $1 caps the batch (mirrors the album backfill).
func (q *Queries) ListTracksMissingMbidWithPath(ctx context.Context, limit int32) ([]ListTracksMissingMbidWithPathRow, error) {
rows, err := q.db.Query(ctx, listTracksMissingMbidWithPath, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTracksMissingMbidWithPathRow
for rows.Next() {
var i ListTracksMissingMbidWithPathRow
if err := rows.Scan(&i.ID, &i.FilePath); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const searchTracks = `-- name: SearchTracks :many
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks
WHERE title ILIKE '%' || $1::text || '%'
@@ -437,6 +472,24 @@ func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]T
return items, nil
}
const setTrackMbidIfNull = `-- name: SetTrackMbidIfNull :exec
UPDATE tracks
SET mbid = $2, updated_at = now()
WHERE id = $1 AND mbid IS NULL
`
type SetTrackMbidIfNullParams struct {
ID pgtype.UUID
Mbid *string
}
// Heal a track's recording MBID only while still NULL — idempotent, so
// re-running the backfill is a no-op for already-healed rows.
func (q *Queries) SetTrackMbidIfNull(ctx context.Context, arg SetTrackMbidIfNullParams) error {
_, err := q.db.Exec(ctx, setTrackMbidIfNull, arg.ID, arg.Mbid)
return err
}
const upsertTrack = `-- name: UpsertTrack :one
INSERT INTO tracks (
title, album_id, artist_id, track_number, disc_number,
@@ -0,0 +1,6 @@
-- Reverts to the unique partial index. This FAILS if any recording
-- MBID is shared across releases in the library (the exact case 0029
-- exists to allow) — dedupe tracks.mbid before rolling back.
DROP INDEX IF EXISTS tracks_mbid_idx;
CREATE UNIQUE INDEX tracks_mbid_unique ON tracks (mbid) WHERE mbid IS NOT NULL;
@@ -0,0 +1,15 @@
-- tracks.mbid holds the MusicBrainz *recording* MBID — the id the
-- ListenBrainz similarity pipeline (ListPlayedTracksNeedingSimilarity,
-- GetTracksByMBIDs) matches on. A single recording legitimately appears
-- on multiple releases (album + compilation + single), so multiple
-- tracks rows share one recording MBID.
--
-- tracks_mbid_unique (added in 0002, when this column was always NULL
-- and never populated) wrongly assumes one MBID == one track. Now that
-- the scanner extracts the recording MBID, that uniqueness throws
-- 23505 for any recording present on >1 release. Replace it with a
-- non-unique partial index serving the same lookups. Zero-risk: the
-- column is 100% NULL at migration time.
DROP INDEX IF EXISTS tracks_mbid_unique;
CREATE INDEX IF NOT EXISTS tracks_mbid_idx ON tracks (mbid) WHERE mbid IS NOT NULL;
@@ -0,0 +1,15 @@
-- Restore the 0020 fixed-allowlist CHECKs. FAILS if any row holds a
-- source value outside the allowlist (the exact case 0030 enables) —
-- normalise such rows before rolling back.
ALTER TABLE albums DROP CONSTRAINT IF EXISTS albums_cover_art_source_check;
ALTER TABLE albums
ADD CONSTRAINT albums_cover_art_source_check
CHECK (cover_art_source IS NULL
OR cover_art_source IN ('embedded','sidecar','mbcaa','theaudiodb','deezer','lastfm','none'));
ALTER TABLE artists DROP CONSTRAINT IF EXISTS artists_artist_art_source_check;
ALTER TABLE artists
ADD CONSTRAINT artists_artist_art_source_check
CHECK (artist_art_source IS NULL
OR artist_art_source IN ('theaudiodb','deezer','lastfm','none'));
@@ -0,0 +1,19 @@
-- The cover/artist art `*_source` column stores the recording
-- provider's registry ID. coverart.Register makes the provider set
-- extensible, so a fixed IN-list CHECK (0016 → 0018 → 0020) required a
-- schema migration for every new provider and rejected any
-- out-of-list value (e.g. test stub providers). Same brittleness
-- class as the discovery-mix CHECK incident (#433): a fixed-value
-- CHECK fighting an extensible value set. Relax to "NULL or any
-- non-empty string" — the registry, not the schema, is the source of
-- truth for valid provider IDs.
ALTER TABLE albums DROP CONSTRAINT IF EXISTS albums_cover_art_source_check;
ALTER TABLE albums
ADD CONSTRAINT albums_cover_art_source_check
CHECK (cover_art_source IS NULL OR cover_art_source <> '');
ALTER TABLE artists DROP CONSTRAINT IF EXISTS artists_artist_art_source_check;
ALTER TABLE artists
ADD CONSTRAINT artists_artist_art_source_check
CHECK (artist_art_source IS NULL OR artist_art_source <> '');
+45 -20
View File
@@ -38,24 +38,46 @@ SELECT t.id, t.album_id, t.artist_id
-- name: ListRediscoverTracks :many
-- #420 Rediscover: tracks the user played a lot (>=5 non-skip) but
-- not in the last 6 months. Ordered by historical affection.
-- $1 user_id.
-- has drifted away from. Tiered so a young library still gets a mix:
-- tier 0 not played in the last 6 months (true rediscovery)
-- tier 1 (only if tier 0 empty) not played in the last 30 days
-- Ordered by historical affection. $1 user_id.
WITH stats AS (
SELECT track_id, COUNT(*) AS c, MAX(started_at) AS last_at
FROM play_events
WHERE user_id = $1 AND was_skipped = false
GROUP BY track_id
SELECT pe.track_id, COUNT(*) AS c, MAX(pe.started_at) AS last_at
FROM play_events pe
WHERE pe.user_id = $1 AND pe.was_skipped = false
GROUP BY pe.track_id
),
deep AS (
SELECT t.id, t.album_id, t.artist_id, s.c, 0 AS tier
FROM tracks t
JOIN stats s ON s.track_id = t.id
WHERE s.c >= 5
AND s.last_at <= now() - interval '6 months'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
),
shallow AS (
SELECT t.id, t.album_id, t.artist_id, s.c, 1 AS tier
FROM tracks t
JOIN stats s ON s.track_id = t.id
WHERE s.c >= 5
AND s.last_at <= now() - interval '30 days'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
)
SELECT t.id, t.album_id, t.artist_id
FROM tracks t
JOIN stats s ON s.track_id = t.id
WHERE s.c >= 5
AND s.last_at <= now() - interval '6 months'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY s.c DESC, t.id
SELECT id, album_id, artist_id
FROM (
SELECT id, album_id, artist_id, c, tier FROM deep
UNION ALL
SELECT id, album_id, artist_id, c, tier FROM shallow
WHERE NOT EXISTS (SELECT 1 FROM deep)
) u
ORDER BY tier, c DESC, id
LIMIT 200;
-- name: ListNewForYouTracks :many
@@ -87,16 +109,19 @@ SELECT t.id, t.album_id, t.artist_id
-- name: ListOnThisDayTracks :many
-- #422 On This Day: tracks the user played around this calendar date
-- (±7 day-of-year) in the past, excluding the very recent (>60 days
-- (±10 day-of-year) in the past, excluding the very recent (>30 days
-- ago) so it's nostalgic, not just "last week". Weighted by how much
-- they were played in those windows.
-- they were played in those windows. Floor relaxed from 60→30 days
-- and window ±7→±10 so it surfaces on a months-old library instead
-- of needing a full year of history; still skips cleanly (no rows →
-- no playlist) when there's no qualifying history yet.
-- $1 user_id, $2 date string.
WITH windowed AS (
SELECT track_id, COUNT(*) AS c
FROM play_events
WHERE user_id = $1 AND was_skipped = false
AND started_at < now() - interval '60 days'
AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 7
AND started_at < now() - interval '30 days'
AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 10
GROUP BY track_id
)
SELECT t.id, t.album_id, t.artist_id
+44 -14
View File
@@ -73,20 +73,50 @@ SELECT p.artist_id,
LIMIT 5;
-- name: PickTopPlayedTracksForUser :many
-- For-You candidate seeds. Returns the user's top-5 most-played
-- non-skipped tracks in the last 7 days; tie-break by track_id for
-- determinism. The Go-side picker (pickForYouSeedForDay) chooses one
-- of the returned rows as today's seed via userIDHash so the
-- candidate pool rotates day-to-day while staying stable within a
-- day.
SELECT t.id
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
AND pe.started_at > now() - INTERVAL '7 days'
AND pe.was_skipped = false
GROUP BY t.id
ORDER BY COUNT(*) DESC, t.id
-- For-You candidate seeds, tiered so For-You never silently vanishes:
-- tier 0 top non-skip plays in the last 30 days
-- tier 1 (only if tier 0 empty) all-time top non-skip plays
-- tier 2 (only if tiers 0+1 empty) liked tracks
-- Returns up to 5 ids; tie-break by track_id for determinism. The
-- Go-side picker (pickForYouSeedForDay) rotates one per day via
-- userIDHash. Widened from a hard 7-day window, which made For-You
-- disappear after a week of not listening and never recover on a
-- self-hosted library with sparse history.
WITH recent AS (
SELECT t.id, COUNT(*) AS c, 0 AS tier
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
AND pe.started_at > now() - INTERVAL '30 days'
AND pe.was_skipped = false
GROUP BY t.id
),
alltime AS (
SELECT t.id, COUNT(*) AS c, 1 AS tier
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
AND pe.was_skipped = false
GROUP BY t.id
),
liked AS (
SELECT gl.track_id AS id, 0::bigint AS c, 2 AS tier
FROM general_likes gl
WHERE gl.user_id = $1
),
chosen AS (
SELECT id, c, tier FROM recent
UNION ALL
SELECT id, c, tier FROM alltime
WHERE NOT EXISTS (SELECT 1 FROM recent)
UNION ALL
SELECT id, c, tier FROM liked
WHERE NOT EXISTS (SELECT 1 FROM recent)
AND NOT EXISTS (SELECT 1 FROM alltime)
)
SELECT id
FROM chosen
ORDER BY tier, c DESC, id
LIMIT 5;
-- name: PickTopPlayedTrackForArtistByUser :one
+16
View File
@@ -19,6 +19,22 @@ ON CONFLICT (file_path) DO UPDATE SET
updated_at = now()
RETURNING *;
-- name: ListTracksMissingMbidWithPath :many
-- Track recording-MBID backfill: tracks with NULL mbid that still have
-- a file to re-read. $1 caps the batch (mirrors the album backfill).
SELECT id, file_path
FROM tracks
WHERE mbid IS NULL
ORDER BY id
LIMIT $1;
-- name: SetTrackMbidIfNull :exec
-- Heal a track's recording MBID only while still NULL — idempotent, so
-- re-running the backfill is a no-op for already-healed rows.
UPDATE tracks
SET mbid = $2, updated_at = now()
WHERE id = $1 AND mbid IS NULL;
-- name: GetTrackByID :one
SELECT * FROM tracks WHERE id = $1;
+17
View File
@@ -55,6 +55,13 @@ var dataTables = []string{
"playlist_tracks",
"playlists",
"library_changes", // M7 #357 — must reset to keep cursor isolated per test
// SettingsService.reconcile() idempotently re-UpsertProviderSettings
// for every registered provider at boot, so truncating this is the
// correct per-test reset (clears test-modified enabled/api_key rows).
// cover_art_sources_meta is NOT truncated — boot only READS it
// (never recreates the singleton, seeded once by 0018); ResetDB
// resets its counter via UPDATE below instead.
"cover_art_provider_settings",
"tracks",
"albums",
"artists",
@@ -76,4 +83,14 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
); err != nil {
t.Fatalf("dbtest.ResetDB delete test users: %v", err)
}
// Reset the monotonic cover-art source-version counter to its
// post-migration seeded value. Truncating the row would break
// SettingsService boot, which reads (never recreates) this
// singleton; an UPDATE keeps the row while clearing cross-test
// version accumulation (CurrentVersion=4 want 1, key-only-bump).
if _, err := pool.Exec(ctx,
"UPDATE cover_art_sources_meta SET current_version = 1",
); err != nil {
t.Fatalf("dbtest.ResetDB reset cover-art version: %v", err)
}
}
+93
View File
@@ -167,3 +167,96 @@ func readMBIDsForFile(path string, logger *slog.Logger) (albumMBID, artistMBID s
}
return extractMBIDs(meta)
}
// BackfillTrackMBIDsResult tallies the track recording-MBID backfill.
type BackfillTrackMBIDsResult struct {
Processed int // Tracks considered (mbid IS NULL).
Healed int // Tracks whose recording MBID we set.
Skipped int // Tracks whose file was missing / untagged / write failed.
}
// BackfillTrackMBIDs walks tracks with NULL mbid, re-reads each file's
// tags, and persists the MusicBrainz recording MBID. This is the
// counterpart to BackfillMBIDs (which heals album/artist MBIDs); it
// unblocks the ListenBrainz similarity pipeline, which is gated on
// tracks.mbid IS NOT NULL.
//
// Idempotent via SetTrackMbidIfNull: re-running costs the same N file
// reads but is a no-op for already-healed rows. limit caps one call;
// pass -1 for unbounded (caller normally batches per scan).
func BackfillTrackMBIDs(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger,
limit int, progressCb func(BackfillTrackMBIDsResult)) (BackfillTrackMBIDsResult, error) {
q := dbq.New(pool)
queryLimit := int32(limit)
if limit < 0 {
queryLimit = 1<<31 - 1 // effectively unbounded
}
rows, err := q.ListTracksMissingMbidWithPath(ctx, queryLimit)
if err != nil {
return BackfillTrackMBIDsResult{}, fmt.Errorf("list tracks missing mbid: %w", err)
}
var res BackfillTrackMBIDsResult
for i, r := range rows {
if ctx.Err() != nil {
return res, ctx.Err()
}
res.Processed++
rec := readRecordingMBIDForFile(r.FilePath, logger)
if rec == "" {
res.Skipped++
if progressCb != nil {
progressCb(res)
}
continue
}
m := rec
if uerr := q.SetTrackMbidIfNull(ctx, dbq.SetTrackMbidIfNullParams{
ID: r.ID, Mbid: &m,
}); uerr != nil {
logger.Warn("track mbid backfill: set failed",
"track_id", r.ID, "err", uerr)
res.Skipped++
if progressCb != nil {
progressCb(res)
}
continue
}
res.Healed++
if (i+1)%500 == 0 {
logger.Info("track mbid backfill progress",
"processed", res.Processed, "healed", res.Healed, "skipped", res.Skipped)
}
if progressCb != nil {
progressCb(res)
}
}
logger.Info("track mbid backfill complete",
"processed", res.Processed, "healed", res.Healed, "skipped", res.Skipped)
return res, nil
}
// readRecordingMBIDForFile opens one audio file and returns its
// MusicBrainz recording MBID, or "" when the file can't be opened, the
// tag read fails, or the tag is absent. Errors are logged at Warn, not
// propagated — one broken file must not halt the backfill.
func readRecordingMBIDForFile(path string, logger *slog.Logger) string {
f, err := os.Open(path)
if err != nil {
logger.Warn("track mbid backfill: open failed", "path", path, "err", err)
return ""
}
defer func() { _ = f.Close() }()
meta, err := tag.ReadFrom(f)
if err != nil {
logger.Warn("track mbid backfill: tag read failed", "path", path, "err", err)
return ""
}
return extractRecordingMBID(meta)
}
+14
View File
@@ -27,6 +27,20 @@ func extractMBIDs(m tag.Metadata) (albumMBID, artistMBID string) {
return cleanMBID(info.Get(mbz.Album)), cleanMBID(info.Get(mbz.Artist))
}
// extractRecordingMBID reads the MusicBrainz *recording* ID — Picard's
// musicbrainz_recordingid, surfaced by dhowden/tag as mbz.Recording
// (tag display name "MusicBrainz Track Id"). This is the id the
// ListenBrainz Labs similar-recordings API keys on and what tracks.mbid
// stores.
//
// Deliberately NOT mbz.Track ("MusicBrainz Release Track Id"): that is
// per-release-track, whereas similarity is per-recording. Kept separate
// from extractMBIDs so its existing (album, artist) signature and unit
// tests stay untouched.
func extractRecordingMBID(m tag.Metadata) string {
return cleanMBID(mbz.Extract(m).Get(mbz.Recording))
}
func cleanMBID(s string) string {
if s == "" {
return ""
+8
View File
@@ -142,6 +142,7 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta
return fmt.Errorf("tag read: %w", err)
}
albumMBID, artistMBID := extractMBIDs(meta)
recordingMBID := extractRecordingMBID(meta)
artistName := meta.Artist()
if artistName == "" {
@@ -195,6 +196,13 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta
if g := meta.Genre(); g != "" {
params.Genre = &g
}
// Recording MBID feeds the ListenBrainz similarity pipeline.
// UpsertTrack heals mbid on the file_path conflict, so a re-scan
// of a previously-untagged-into-DB track backfills it for free.
if recordingMBID != "" {
m := recordingMBID
params.Mbid = &m
}
track, err := q.UpsertTrack(ctx, params)
if err != nil {
+11
View File
@@ -103,6 +103,17 @@ func TestScanner_Integration(t *testing.T) {
t.Errorf("artist sort = [%q, %q], want [Artist X, Artist Y]", artists[0].SortName, artists[1].SortName)
}
// The synthetic MP3s carry only an ID3 tag — no decodable audio —
// so ffprobe yields duration 0. The scanner deliberately refuses to
// skip zero-duration rows (it re-runs them so a later scan can
// backfill duration once probing works), which is orthogonal to the
// mtime-based incremental-skip this test covers. Simulate a
// normally-probed library so the skip path is actually exercised;
// updated_at is left untouched (still ≥ file mtime).
if _, err := pool.Exec(ctx, "UPDATE tracks SET duration_ms = 1000 WHERE duration_ms = 0"); err != nil {
t.Fatalf("seed durations: %v", err)
}
stats2, err := scanner.Scan(ctx, nil)
if err != nil {
t.Fatalf("second scan: %v", err)
+21 -3
View File
@@ -49,9 +49,11 @@ type ArtistArtEnrichStageTallies struct {
Failed int `json:"failed"`
}
// RunScanConfig assembles the knobs for the orchestrator. BackfillCap and
// EnrichCap mirror the existing boot-time caps (5000 / coverArtBackfillCap)
// so manual triggers don't accidentally drain the whole library.
// RunScanConfig assembles the knobs for the orchestrator. BackfillCap is
// the album/artist-MBID-backfill batch cap (5000); EnrichCap /
// ArtistEnrichCap gate cover/artist-art enrichment (0 = off, <0 =
// unbounded — #388 removed the global cover-art cap; remote providers
// self-throttle per provider).
type RunScanConfig struct {
BackfillCap int
EnrichCap int
@@ -154,6 +156,22 @@ func RunScan(
}
}
// Stage 2b: track recording-MBID backfill. Unblocks the ListenBrainz
// similarity pipeline (gated on tracks.mbid IS NOT NULL). Log-only
// progress — no scan_runs jsonb column to avoid a schema addition.
//
// Unbounded (-1), unlike the album backfill's 5000 staged cap: this
// is a one-time whole-library heal with no progress UI, and a cap
// just means tracks.mbid stays partially NULL until N future scans
// catch up (re-reading untagged files wastefully each pass). One
// uncapped pass converges; subsequent scans only re-read the
// remaining NULL (genuinely untagged) rows, which is cheap.
if cfg.BackfillCap != 0 {
if _, tberr := BackfillTrackMBIDs(ctx, pool, logger, -1, nil); tberr != nil {
captureErr("track_mbid_backfill", tberr)
}
}
// Stage 3: cover enrichment.
if enricher != nil && cfg.EnrichCap != 0 {
var lastP, lastS, lastF int
+13 -2
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgtype"
@@ -236,10 +237,20 @@ func TestListForUser_OnlyOwnRows(t *testing.T) {
func approveTestSetup(t *testing.T) (*Service, pgtype.UUID, *pgxpool.Pool, *httptest.Server) {
t.Helper()
pool := newPool(t)
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":1}`))
// Approve resolves metadata/quality profiles before the add;
// those list endpoints return JSON arrays, not the {"id":1}
// object the add endpoints return.
switch {
case strings.Contains(r.URL.Path, "/metadataprofile"):
_, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
case strings.Contains(r.URL.Path, "/qualityprofile"):
_, _ = w.Write([]byte(`[{"id":7,"name":"Lossless"}]`))
default:
_, _ = w.Write([]byte(`{"id":1}`))
}
}))
t.Cleanup(stub.Close)
+18 -1
View File
@@ -289,6 +289,23 @@ var systemPlaylistRegistry = []systemPlaylistKind{
{Key: "first_listens", Singleton: true, Produce: produceFirstListens},
}
// systemForYouSourceLimits is a deeper candidate pool than the radio
// default. On a self-hosted library without ListenBrainz similarity
// data the lb_similar / similar_artists sources contribute nothing,
// so the default (~130 raw, ~40 after dedup + diversity caps) can
// never fill For-You's 100-track head/tail. The raised random/tag
// fill keeps For-You ~100 deep regardless of LB enrichment; when LB
// data IS present the larger lb/similar K just makes it richer.
func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
return recommendation.CandidateSourceLimits{
LBSimilar: 80,
SimilarArtist: 80,
TagOverlap: 60,
LikesOverlap: 40,
RandomFill: 150,
}
}
// produceForYou: today's seed from the user's top-5 played tracks
// (rotates daily via userIDHash), similarity candidate pool, head+
// tail composition. The base seed query failing is fatal; a
@@ -311,7 +328,7 @@ func produceForYou(
1, // recentlyPlayedHours — small to avoid filtering the seed's recent neighbourhood
zeroVec,
[]pgtype.UUID{forYouSeed},
recommendation.DefaultCandidateSourceLimits(),
systemForYouSourceLimits(),
)
if cerr != nil {
logger.Warn("system playlist: for-you candidates load failed; skipping",
+16 -6
View File
@@ -24,9 +24,16 @@ func discardLogger() *slog.Logger {
// `InsertPlayEvent` doesn't accept was_skipped (that's set by an UPDATE).
func seedPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, wasSkipped bool) {
t.Helper()
// play_events.session_id has a FK to play_sessions; create a parent
// session in the same statement (a random UUID violates the FK).
_, err := pool.Exec(context.Background(), `
WITH s AS (
INSERT INTO play_sessions (user_id, started_at, last_event_at)
VALUES ($1, $3, $3)
RETURNING id
)
INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped)
VALUES ($1, $2, gen_random_uuid(), $3, $4)
SELECT $1, $2, s.id, $3, $4 FROM s
`, userID, trackID, startedAt, wasSkipped)
if err != nil {
t.Fatalf("seed play_event: %v", err)
@@ -38,7 +45,7 @@ func seedQuarantine(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUI
t.Helper()
_, err := pool.Exec(context.Background(), `
INSERT INTO lidarr_quarantine (user_id, track_id, reason)
VALUES ($1, $2, 'test-hide')
VALUES ($1, $2, 'other')
`, userID, trackID)
if err != nil {
t.Fatalf("seed quarantine: %v", err)
@@ -105,16 +112,19 @@ func TestBuildSystemPlaylists_SufficientActivity(t *testing.T) {
if !r.SeedArtistID.Valid {
t.Errorf("songs_like_artist row should have non-NULL seed_artist_id")
}
case "discover":
// Discover playlist is valid — no seed_artist_id required.
case "discover", "deep_cuts", "rediscover", "new_for_you", "on_this_day", "first_listens":
// Discover + the #411 discovery mixes are all seedless —
// no seed_artist_id required.
default:
t.Errorf("unknown system_variant=%q", *r.SystemVariant)
}
if r.TrackCount == 0 {
t.Errorf("row %s: empty track_count", uuidString(r.ID))
}
if r.TrackCount > 25 {
t.Errorf("row %s: track_count=%d exceeds 25", uuidString(r.ID), r.TrackCount)
// For-You / Discover / the discovery mixes are sized to ~100
// (#352/#411); songs_like_artist stays at systemMixLength (25).
if r.TrackCount > 100 {
t.Errorf("row %s: track_count=%d exceeds 100", uuidString(r.ID), r.TrackCount)
}
}
if !hasForYou {
+54 -33
View File
@@ -17,6 +17,13 @@ import (
const defaultBaseURL = "https://api.listenbrainz.org"
// defaultLabsBaseURL is the ListenBrainz *Labs* API — a DIFFERENT host
// from the main API. Similarity datasets (similar-recordings /
// similar-artists) live here, not under api.listenbrainz.org. The old
// api.listenbrainz.org/1/explore/... paths are website routes, not API
// endpoints, and 404 for every request.
const defaultLabsBaseURL = "https://labs.api.listenbrainz.org"
// Listen is one row sent to ListenBrainz.
type Listen struct {
ListenedAt int64 // unix seconds
@@ -35,18 +42,22 @@ type Track struct {
ReleaseMBID string
}
// Client posts to the LB submit-listens endpoint.
// Client posts to the LB submit-listens endpoint and queries the Labs
// API for similarity. BaseURL is the main API; LabsBaseURL is the
// separate Labs host (see defaultLabsBaseURL).
type Client struct {
BaseURL string
HTTP *http.Client
BaseURL string
LabsBaseURL string
HTTP *http.Client
}
// NewClient returns a default-configured client (LB production base URL,
// 30s timeout).
// NewClient returns a default-configured client (LB production base
// URLs, 30s timeout).
func NewClient() *Client {
return &Client{
BaseURL: defaultBaseURL,
HTTP: &http.Client{Timeout: 30 * time.Second},
BaseURL: defaultBaseURL,
LabsBaseURL: defaultLabsBaseURL,
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
@@ -181,26 +192,31 @@ func buildPayload(listenType string, listens []Listen) payload {
return payload{ListenType: listenType, Payload: entries}
}
// LB algorithm parameter for /explore/similar-recordings/. Hardcoded for v1
// — matches LB's documented default.
const lbSimilarRecordingsAlgorithm = "session_based_days_7500_session_30_contribution_5_threshold_15_limit_100_filter_True_skip_30"
// lbSimilarRecordingsAlgorithm is a Labs-API-valid algorithm enum (the
// value is verified against labs.api.listenbrainz.org; the old
// "_session_30_…_limit_100_filter_True…" string is NOT a permitted
// member and 400s). limit_50 is baked into the name — there is no
// separate count/limit query param.
const lbSimilarRecordingsAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
// SimilarRecording is one entry in the /explore/similar-recordings response.
// SimilarRecording is one entry in the Labs similar-recordings response.
type SimilarRecording struct {
MBID string `json:"recording_mbid"`
Score float64 `json:"score"`
}
// SimilarRecordings fetches up to `limit` similar recordings for the given
// recording MBID. Public endpoint — no token required. Returns the same
// typed errors as SubmitListens.
func (c *Client) SimilarRecordings(ctx context.Context, mbid string, limit int) ([]SimilarRecording, error) {
base := c.BaseURL
// SimilarRecordings fetches similar recordings for the given recording
// MBID from the ListenBrainz Labs API. Public endpoint — no token
// required. The result count is fixed by the algorithm (limit_50); the
// caller applies its own top-K. Returns the same typed errors as
// SubmitListens.
func (c *Client) SimilarRecordings(ctx context.Context, mbid string, _ int) ([]SimilarRecording, error) {
base := c.LabsBaseURL
if base == "" {
base = defaultBaseURL
base = defaultLabsBaseURL
}
url := fmt.Sprintf("%s/1/explore/similar-recordings/%s?algorithm=%s&count=%d",
base, mbid, lbSimilarRecordingsAlgorithm, limit)
url := fmt.Sprintf("%s/similar-recordings/json?recording_mbids=%s&algorithm=%s",
base, mbid, lbSimilarRecordingsAlgorithm)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("listenbrainz: build similar-recordings request: %w", err)
@@ -234,29 +250,34 @@ func (c *Client) SimilarRecordings(ctx context.Context, mbid string, limit int)
}
}
// LB algorithm parameter for /explore/similar-artists/. Hardcoded for v1
// — matches LB's documented default.
const lbSimilarArtistsAlgorithm = "session_based_days_7500_session_30_contribution_5_threshold_15_limit_100_filter_True_skip_30"
// lbSimilarArtistsAlgorithm is a Labs-API-valid algorithm enum (verified
// against labs.api.listenbrainz.org; shares the same permitted set as
// similar-recordings). The old value 400s. limit_50 is baked in — no
// separate count/limit query param.
const lbSimilarArtistsAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
// SimilarArtist is one entry in the /explore/similar-artists response.
// Name is captured from the LB payload so M5c can render the artist's
// name on out-of-library suggestions without an extra MusicBrainz lookup.
// SimilarArtist is one entry in the Labs similar-artists response. Name
// is captured from the LB payload so M5c can render the artist's name
// on out-of-library suggestions without an extra MusicBrainz lookup.
// (The Labs payload also carries comment/type/gender/reference_mbid;
// those are intentionally ignored.)
type SimilarArtist struct {
MBID string `json:"artist_mbid"`
Name string `json:"name"`
Score float64 `json:"score"`
}
// SimilarArtists fetches up to `limit` similar artists for the given
// artist MBID. Public endpoint — no token required. Returns the same
// typed errors as SubmitListens.
func (c *Client) SimilarArtists(ctx context.Context, mbid string, limit int) ([]SimilarArtist, error) {
base := c.BaseURL
// SimilarArtists fetches similar artists for the given artist MBID from
// the ListenBrainz Labs API. Public endpoint — no token required. The
// result count is fixed by the algorithm (limit_50); the caller applies
// its own top-K. Returns the same typed errors as SubmitListens.
func (c *Client) SimilarArtists(ctx context.Context, mbid string, _ int) ([]SimilarArtist, error) {
base := c.LabsBaseURL
if base == "" {
base = defaultBaseURL
base = defaultLabsBaseURL
}
url := fmt.Sprintf("%s/1/explore/similar-artists/%s?algorithm=%s&count=%d",
base, mbid, lbSimilarArtistsAlgorithm, limit)
url := fmt.Sprintf("%s/similar-artists/json?artist_mbids=%s&algorithm=%s",
base, mbid, lbSimilarArtistsAlgorithm)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("listenbrainz: build similar-artists request: %w", err)
+15 -7
View File
@@ -12,7 +12,7 @@ import (
func newTestClient(handler http.HandlerFunc) (*Client, *httptest.Server) {
srv := httptest.NewServer(handler)
return &Client{BaseURL: srv.URL, HTTP: srv.Client()}, srv
return &Client{BaseURL: srv.URL, LabsBaseURL: srv.URL, HTTP: srv.Client()}, srv
}
func TestClient_SubmitListens_Success(t *testing.T) {
@@ -196,7 +196,7 @@ func TestClient_SimilarRecordings_AlgorithmParamSet(t *testing.T) {
}
}
func TestClient_SimilarRecordings_LimitParamSet(t *testing.T) {
func TestClient_SimilarRecordings_MbidParamSet(t *testing.T) {
var seenURL string
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
seenURL = r.URL.String()
@@ -204,8 +204,13 @@ func TestClient_SimilarRecordings_LimitParamSet(t *testing.T) {
})
defer srv.Close()
_, _ = c.SimilarRecordings(context.Background(), "abc", 50)
if !strings.Contains(seenURL, "count=50") && !strings.Contains(seenURL, "limit=50") {
t.Errorf("URL missing count/limit param: %q", seenURL)
// Labs API takes the MBID as a query param, not a path segment; the
// result count is fixed by the algorithm (no count/limit param).
if !strings.Contains(seenURL, "recording_mbids=abc") {
t.Errorf("URL missing recording_mbids param: %q", seenURL)
}
if !strings.Contains(seenURL, "/similar-recordings/json") {
t.Errorf("URL not the Labs similar-recordings endpoint: %q", seenURL)
}
}
@@ -291,7 +296,7 @@ func TestClient_SimilarArtists_AlgorithmParamSet(t *testing.T) {
}
}
func TestClient_SimilarArtists_LimitParamSet(t *testing.T) {
func TestClient_SimilarArtists_MbidParamSet(t *testing.T) {
var seenURL string
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
seenURL = r.URL.String()
@@ -299,8 +304,11 @@ func TestClient_SimilarArtists_LimitParamSet(t *testing.T) {
})
defer srv.Close()
_, _ = c.SimilarArtists(context.Background(), "abc", 50)
if !strings.Contains(seenURL, "count=50") && !strings.Contains(seenURL, "limit=50") {
t.Errorf("URL missing count/limit param: %q", seenURL)
if !strings.Contains(seenURL, "artist_mbids=abc") {
t.Errorf("URL missing artist_mbids param: %q", seenURL)
}
if !strings.Contains(seenURL, "/similar-artists/json") {
t.Errorf("URL not the Labs similar-artists endpoint: %q", seenURL)
}
}
+10 -11
View File
@@ -72,14 +72,13 @@ type Server struct {
// playlist cover collages under <DataDir>/playlist_covers/). Empty
// strings are tolerated by tests that don't exercise persisted-cover
// codepaths; production callers should pass a writable directory.
DataDir string
BrandingCfg config.BrandingConfig
CoverEnricher *coverart.Enricher
CoverArtBackfillCap int
CoverSettings *coverart.SettingsService
LibraryScanner *library.Scanner
ScanCfg library.RunScanConfig
Scheduler *library.Scheduler
DataDir string
BrandingCfg config.BrandingConfig
CoverEnricher *coverart.Enricher
CoverSettings *coverart.SettingsService
LibraryScanner *library.Scanner
ScanCfg library.RunScanConfig
Scheduler *library.Scheduler
// Bus is the live-event bus shared with background workers (the
// lidarr reconciler, scan scheduler) constructed in cmd/minstrel/main.go.
// When nil, Router() constructs a local fallback (test contexts).
@@ -92,8 +91,8 @@ type Server struct {
PlaylistScheduler *playlists.Scheduler
}
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig, dataDir string, brandingCfg config.BrandingConfig, coverEnricher *coverart.Enricher, coverArtBackfillCap int, coverSettings *coverart.SettingsService, libraryScanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler) *Server {
return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg, RecommendationCfg: recCfg, DataDir: dataDir, BrandingCfg: brandingCfg, CoverEnricher: coverEnricher, CoverArtBackfillCap: coverArtBackfillCap, CoverSettings: coverSettings, LibraryScanner: libraryScanner, ScanCfg: scanCfg, Scheduler: scheduler}
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig, dataDir string, brandingCfg config.BrandingConfig, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, libraryScanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler) *Server {
return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg, RecommendationCfg: recCfg, DataDir: dataDir, BrandingCfg: brandingCfg, CoverEnricher: coverEnricher, CoverSettings: coverSettings, LibraryScanner: libraryScanner, ScanCfg: scanCfg, Scheduler: scheduler}
}
func (s *Server) Router() http.Handler {
@@ -139,7 +138,7 @@ func (s *Server) Router() http.Handler {
if bus == nil {
bus = eventbus.New()
}
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverArtBackfillCap, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.Scheduler, s.DataDir, smtpSender, bus, s.PlaylistScheduler)
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.Scheduler, s.DataDir, smtpSender, bus, s.PlaylistScheduler)
// /api/admin/scan is the only admin route owned by the server package
// (it needs the Scanner). Register it as a single inline-middleware
// route — using r.Route("/api/admin", ...) here would create a second
+6 -6
View File
@@ -24,7 +24,7 @@ import (
)
func TestHealthz(t *testing.T) {
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0, nil, nil, library.RunScanConfig{}, nil)
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{}, nil)
ts := httptest.NewServer(s.Router())
defer ts.Close()
@@ -73,7 +73,7 @@ func TestHealthz_IncludesMinClientVersion(t *testing.T) {
}
func TestRouter_ServesSPAAtRoot(t *testing.T) {
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0, nil, nil, library.RunScanConfig{}, nil)
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{}, nil)
ts := httptest.NewServer(s.Router())
defer ts.Close()
@@ -92,7 +92,7 @@ func TestRouter_ServesSPAAtRoot(t *testing.T) {
}
func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) {
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0, nil, nil, library.RunScanConfig{}, nil)
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{}, nil)
ts := httptest.NewServer(s.Router())
defer ts.Close()
@@ -111,7 +111,7 @@ func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) {
}
func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) {
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0, nil, nil, library.RunScanConfig{}, nil)
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{}, nil)
ts := httptest.NewServer(s.Router())
defer ts.Close()
@@ -130,7 +130,7 @@ func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) {
}
func TestRouter_RestPathNotSwallowedBySPA(t *testing.T) {
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0, nil, nil, library.RunScanConfig{}, nil)
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{}, nil)
ts := httptest.NewServer(s.Router())
defer ts.Close()
@@ -206,7 +206,7 @@ func TestRouter_AdminSubtreeNotShadowed(t *testing.T) {
}
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), pool,
stubScanner{}, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0, nil, nil, library.RunScanConfig{}, nil)
stubScanner{}, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{}, nil)
ts := httptest.NewServer(s.Router())
defer ts.Close()
+9 -7
View File
@@ -1,9 +1,9 @@
// Package similarity owns the inbound ListenBrainz similarity ingest
// pipeline. A periodic worker queries LB's /explore/similar-recordings
// and /explore/similar-artists endpoints for tracks the user has played,
// filters returned MBIDs to the local library, and stores the top-K
// edges in track_similarity / artist_similarity for M4c's radio
// candidate-pool builder.
// pipeline. A periodic worker queries the LB Labs API
// (labs.api.listenbrainz.org similar-recordings / similar-artists) for
// tracks the user has played, filters returned MBIDs to the local
// library, and stores the top-K edges in track_similarity /
// artist_similarity for M4c's radio candidate-pool builder.
package similarity
import (
@@ -34,14 +34,16 @@ type Worker struct {
}
// NewWorker constructs a worker with production defaults: 1h tick,
// batch=5, topK=20.
// batch=25, topK=20. batch is tracks AND artists processed per tick;
// at 25/h a freshly-played library converges in hours, not days, while
// staying well under ListenBrainz rate limits (429s abort the tick).
func NewWorker(pool *pgxpool.Pool, client *listenbrainz.Client, logger *slog.Logger) *Worker {
return &Worker{
pool: pool,
client: client,
logger: logger,
tick: 1 * time.Hour,
batch: 5,
batch: 25,
topK: 20,
}
}
@@ -114,8 +114,11 @@ func markPlayed(t *testing.T, f fixture, trackID pgtype.UUID) {
func newTestWorker(f fixture, lbBaseURL string) *Worker {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
return &Worker{
pool: f.pool,
client: &listenbrainz.Client{BaseURL: lbBaseURL, HTTP: http.DefaultClient},
pool: f.pool,
// LabsBaseURL must point at the stub too: similarity calls hit
// the Labs API (commit 4fca0e6), so an unset LabsBaseURL would
// fall through to the real labs.api and the stub never runs.
client: &listenbrainz.Client{BaseURL: lbBaseURL, LabsBaseURL: lbBaseURL, HTTP: http.DefaultClient},
logger: logger,
tick: 1 * time.Hour,
batch: 5,
+2 -2
View File
@@ -13,8 +13,8 @@ func TestNewWorker_DefaultsMatchSpec(t *testing.T) {
if w.tick != 1*time.Hour {
t.Errorf("tick = %v, want 1h", w.tick)
}
if w.batch != 5 {
t.Errorf("batch = %d, want 5", w.batch)
if w.batch != 25 {
t.Errorf("batch = %d, want 25", w.batch)
}
if w.topK != 20 {
t.Errorf("topK = %d, want 20", w.topK)
+2 -2
View File
@@ -25,9 +25,9 @@ describe('admin covers API', () => {
});
it('refetchMissingCovers POSTs to the bulk endpoint', async () => {
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ queued: 7 });
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ started: true });
const got = await refetchMissingCovers();
expect(api.post).toHaveBeenCalledWith('/api/admin/covers/refetch-missing', {});
expect(got.queued).toBe(7);
expect(got.started).toBe(true);
});
});
+1 -1
View File
@@ -187,7 +187,7 @@ export async function refetchAlbumCover(albumId: string): Promise<RefetchAlbumCo
}
export type RefetchMissingResponse = {
queued: number;
started: boolean;
};
export async function refetchMissingCovers(): Promise<RefetchMissingResponse> {
+1
View File
@@ -298,6 +298,7 @@ export type ArtistSuggestion = {
name: string;
score: number;
attribution: SeedContribution[]; // up to 3 entries, ordered by contribution DESC
image_url?: string; // resolved on-demand from Lidarr; absent → card placeholder
};
// Mirrors internal/api/types.go HomePayload. All slices are non-null
@@ -65,6 +65,7 @@
<DiscoverResultCard
kind="artist"
title={s.name}
imageUrl={s.image_url}
state="requestable"
attribution={attributionText(s.attribution)}
onRequest={() => onRequest(s)}
-1
View File
@@ -1,7 +1,6 @@
{
"unknown": "Something went wrong.",
"unauthenticated": "Your session has ended. Please sign in again.",
"auth_required": "You need to sign in to do that.",
"forbidden": "You don't have permission to do that.",
"not_authorized": "You don't have permission to do that.",
"invalid_credentials": "Wrong username or password.",
+4 -2
View File
@@ -318,8 +318,10 @@
bulkBusy = true;
bulkResult = null;
try {
const { queued } = await refetchMissingCovers();
bulkResult = `Queued ${queued} albums for cover refetch.`;
const { started } = await refetchMissingCovers();
bulkResult = started
? 'Refetching all missing covers — local sources are fast, remote providers throttle per their limits.'
: 'Could not start the cover refetch.';
await client.invalidateQueries({ queryKey: qk.coverage() });
} catch (e) {
bulkResult = `Failed: ${errCode(e)}`;
+1 -1
View File
@@ -41,7 +41,7 @@ vi.mock('$lib/api/admin', async () => {
deleteQuarantineFile: vi.fn().mockResolvedValue({}),
deleteQuarantineViaLidarr: vi.fn().mockResolvedValue({}),
triggerScan: vi.fn().mockResolvedValue({}),
refetchMissingCovers: vi.fn().mockResolvedValue({ queued: 0 }),
refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }),
researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }),
createScanScheduleQuery: vi.fn(() =>
readable({