docs: server comments say what the code does, not how it came to be (#6277)

Comment blocks in cmd/minstrel and 30 smaller internal packages, rewritten
to rule 32's standard with each citation read in context: task, milestone
and rule ids go, and so do the stories they were attached to ("used to",
"Before #N", measurements from one install, quotes, pointers to Fabled
Librarian's records and to docs/superpowers files that are not in the
repo). Where a story wrapped a real constraint, the constraint stays as a
present-tense fact.

Comments that had gone stale are corrected:
- taste.Config: the tuned values are overlaid by internal/recsettings;
  it is no longer "a later follow-up".
- StorageConfig: the data dir holds art, caches and the stream secret,
  not only playlist covers.
- the Subsonic album list and getUser comments describe what they do
  today rather than a milestone that would change it.

Checked: the non-comment token stream of every changed file matches
HEAD, and gofmt -l is empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 12:15:05 -04:00
co-authored by Claude Opus 5.5
parent 9f9609591d
commit d191a28217
107 changed files with 485 additions and 597 deletions
+5 -7
View File
@@ -55,14 +55,12 @@ func runAdmin(args []string) error {
} }
} }
// adminResetPassword resets a user's login password (password_hash). It // adminResetPassword resets a user's login password (password_hash), so an
// recovers a locked-out operator when the bootstrap password was missed or // admin who is locked out can get back in without editing the database.
// the DB volume was recreated (Fable #321) without DB surgery.
// //
// It deliberately leaves subsonic_password alone. That column is stored in // It leaves subsonic_password alone: that column is stored in plain text, so
// plain text, and it used to receive the new login password here, so any // writing the login password there would make it readable in the database.
// account recovered this way had its login password readable in the database // The Subsonic password is generated separately in Settings.
// (#5026). The Subsonic password is generated separately in Settings.
func adminResetPassword(args []string) error { func adminResetPassword(args []string) error {
fs := flag.NewFlagSet("admin reset-password", flag.ContinueOnError) fs := flag.NewFlagSet("admin reset-password", flag.ContinueOnError)
configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file") configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
+54 -67
View File
@@ -128,7 +128,7 @@ func run() error {
} }
defer pool.Close() defer pool.Close()
// Fingerprinting settings (M400 #3913): one instance, shared by the scanner, // Fingerprinting settings: one instance, shared by the scanner,
// the fingerprint backfill, the duplicate sweep and the admin API, so a save // the fingerprint backfill, the duplicate sweep and the admin API, so a save
// reaches all of them without a restart. A load failure is logged, not fatal: // reaches all of them without a restart. A load failure is logged, not fatal:
// the service falls back to the shipped defaults. // the service falls back to the shipped defaults.
@@ -136,9 +136,9 @@ func run() error {
if fpErr != nil { if fpErr != nil {
logger.Warn("fingerprint settings: using defaults", "err", fpErr) logger.Warn("fingerprint settings: using defaults", "err", fpErr)
} }
// The library's folders (#6129): the ones saved in Settings, or // The library's folders: the ones saved in Settings, or
// MINSTREL_LIBRARY_SCAN_PATHS until any are. A load failure falls back to // MINSTREL_LIBRARY_SCAN_PATHS until any are. A load failure falls back to
// env, which is what the server read before the setting existed. // the env value.
roots, rootsSaved, rootsErr := library.LoadRoots(ctx, pool, cfg.Library.ScanPaths) roots, rootsSaved, rootsErr := library.LoadRoots(ctx, pool, cfg.Library.ScanPaths)
switch { switch {
case rootsErr != nil: case rootsErr != nil:
@@ -149,14 +149,14 @@ func run() error {
} }
scanner := library.New(pool, logger, roots, fpSettings) scanner := library.New(pool, logger, roots, fpSettings)
// Loudness analysis settings (M464 #4995): shared by the loudness backfill // Loudness analysis settings: shared by the loudness backfill
// and the admin API, and falls back to the defaults like the above. // and the admin API, and falls back to the defaults like the above.
loudSettings, loudErr := library.NewLoudnessSettingsService(ctx, pool) loudSettings, loudErr := library.NewLoudnessSettingsService(ctx, pool)
if loudErr != nil { if loudErr != nil {
logger.Warn("loudness settings: using defaults", "err", loudErr) logger.Warn("loudness settings: using defaults", "err", loudErr)
} }
// AcoustID lookup settings (M401): shared by the lookup worker and the // AcoustID lookup settings: shared by the lookup worker and the
// admin API. A load failure leaves the lookup off. // admin API. A load failure leaves the lookup off.
acoustIDSettings, acoustIDErr := library.NewAcoustIDSettingsService(ctx, pool) acoustIDSettings, acoustIDErr := library.NewAcoustIDSettingsService(ctx, pool)
if acoustIDErr != nil { if acoustIDErr != nil {
@@ -194,10 +194,9 @@ func run() error {
coverEnricher := coverart.NewEnricher(pool, logger.With("component", "coverart"), coverSettings) coverEnricher := coverart.NewEnricher(pool, logger.With("component", "coverart"), coverSettings)
coverEnricher.DataDir = cfg.Storage.DataDir coverEnricher.DataDir = cfg.Storage.DataDir
// Recommendation tuning lab (#1250): seeds shipped defaults on first // Recommendation tuning: seeds the shipped defaults on first boot and
// boot and pushes the daily_mix weights + taste config into package // pushes the daily-mix weights and taste config into package playlists.
// playlists — must precede the scheduler so the first builds score // It must precede the scheduler, so the first builds use the tuned values.
// with the operator's tuned values, not the pre-push literals.
recSettings, err := recsettings.New(ctx, pool, logger.With("component", "recsettings")) recSettings, err := recsettings.New(ctx, pool, logger.With("component", "recsettings"))
if err != nil { if err != nil {
logger.Error("recommendation settings service init failed", "err", err) logger.Error("recommendation settings service init failed", "err", err)
@@ -213,7 +212,7 @@ func run() error {
logger.With("component", "scan_run"), logger.With("component", "scan_run"),
library.RunScanConfig{ library.RunScanConfig{
BackfillCap: 5000, BackfillCap: 5000,
EnrichCap: -1, // #388: no global cap; per-provider rate limits EnrichCap: -1, // no global cap; each provider paces itself
ArtistEnrichCap: -1, ArtistEnrichCap: -1,
DataDir: cfg.Storage.DataDir, DataDir: cfg.Storage.DataDir,
}, },
@@ -223,14 +222,13 @@ func run() error {
}() }()
} else { } else {
// No startup file walk, but still run backfill + enrich so cover // No startup file walk, but still run backfill + enrich so cover
// progress doesn't stall just because the operator disabled // art keeps arriving with scan-on-startup off.
// scan-on-startup.
go func() { go func() {
if _, err := library.RunScan(ctx, pool, nil, coverEnricher, if _, err := library.RunScan(ctx, pool, nil, coverEnricher,
logger.With("component", "scan_run"), logger.With("component", "scan_run"),
library.RunScanConfig{ library.RunScanConfig{
BackfillCap: 5000, BackfillCap: 5000,
EnrichCap: -1, // #388: no global cap; per-provider rate limits EnrichCap: -1, // no global cap; each provider paces itself
ArtistEnrichCap: -1, ArtistEnrichCap: -1,
DataDir: cfg.Storage.DataDir, DataDir: cfg.Storage.DataDir,
}, },
@@ -240,46 +238,44 @@ func run() error {
}() }()
} }
// Start the ListenBrainz scrobble worker. Per spec §M4a, runs every 30s // ListenBrainz scrobble worker: every 30s, drains up to 50 pending rows.
// and drains up to 50 pending rows per tick. Per-user gating happens // Rows from users with scrobbling off are skipped inside the worker.
// inside the worker (rows from disabled users are skipped).
scrobbleWorker := scrobble.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "scrobble")) scrobbleWorker := scrobble.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "scrobble"))
go scrobbleWorker.Run(ctx) go scrobbleWorker.Run(ctx)
// Start the similarity ingest worker. Per spec §M4b, runs every 1h, drains // Similarity ingest worker: hourly, fetches ListenBrainz similarity for up
// up to 5 played tracks + 5 played artists per tick, weekly re-fetch cap // to 5 played tracks and 5 played artists, each re-fetched at most weekly.
// per row. Public LB endpoints — no token required. // The endpoints are public, so no token is needed.
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity")) similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
go similarityWorker.Run(ctx) go similarityWorker.Run(ctx)
// Start the household co-play worker (#1533). Recomputes artist–artist // Household co-play worker: recomputes artist–artist
// co-occurrence edges (source='user_cooccurrence') from play_events every // co-occurrence edges (source='user_cooccurrence') from play_events every
// 6h — a collaborative candidate arm for the radio/mix pools. Pure local // 6h — a collaborative candidate arm for the radio/mix pools. Pure local
// SQL, no external calls; empty on single-user servers. // SQL, no external calls; empty on single-user servers.
go coplay.NewWorker(pool, logger.With("component", "coplay")).Run(ctx) go coplay.NewWorker(pool, logger.With("component", "coplay")).Run(ctx)
// Fingerprint backfill (M400 #3908): fingerprints the tracks the scan never // Fingerprint backfill: fingerprints the tracks the scan skips (unchanged
// will — everything imported before fingerprinting existed, and rows derived // files, and rows fingerprinted by an older method). A worker of its own
// by an older method. A worker of its own rather than a scan stage; see // rather than a scan stage; see internal/library/fingerprint_backfill.go.
// internal/library/fingerprint_backfill.go for why.
go library.NewFingerprintBackfillWorker(pool, logger.With("component", "fingerprint_backfill"), fpSettings).Run(ctx) go library.NewFingerprintBackfillWorker(pool, logger.With("component", "fingerprint_backfill"), fpSettings).Run(ctx)
// Loudness backfill (M464 #4995): measures every track's loudness for // Loudness backfill: measures every track's loudness for
// normalization, new tracks included; the scan only drops a changed file's // normalization, new tracks included; the scan only drops a changed file's
// measurement. See internal/library/loudness_backfill.go. // measurement. See internal/library/loudness_backfill.go.
go library.NewLoudnessBackfillWorker(pool, logger.With("component", "loudness_backfill"), loudSettings).Run(ctx) go library.NewLoudnessBackfillWorker(pool, logger.With("component", "loudness_backfill"), loudSettings).Run(ctx)
// AcoustID lookup (M401): fills the recording MBIDs untagged tracks lack, // AcoustID lookup: fills the recording MBIDs untagged tracks lack, so the
// so the similarity arms can see them. Idle until the operator adds a key // similarity arms can see them. Idle until an admin adds a key and
// and switches it on. See internal/library/acoustid_lookup.go. // switches it on. See internal/library/acoustid_lookup.go.
go acoustIDLookup.Run(ctx) go acoustIDLookup.Run(ctx)
// Duplicate sweep (M400 #3910): proposes groups of tracks holding one // Duplicate sweep: proposes groups of tracks holding one
// recording, from the fingerprints above. Sweeps only when fingerprints have // recording, from the fingerprints above. Sweeps only when fingerprints have
// changed since the last sweep. // changed since the last sweep.
go library.NewDuplicateSweepWorker(pool, logger.With("component", "duplicate_sweep"), fpSettings).Run(ctx) go library.NewDuplicateSweepWorker(pool, logger.With("component", "duplicate_sweep"), fpSettings).Run(ctx)
// Start the tag-enrichment worker (#1490). Reconciles the compiled-in // Tag-enrichment worker: reconciles the compiled-in
// tag providers with tag_provider_settings, bumps the sources version if // tag providers with tag_provider_settings, bumps the sources version if
// the provider set changed (re-opening settled rows), then drains tracks // the provider set changed (re-opening settled rows), then drains tracks
// needing folksonomy tags on a periodic tick. Standalone (not in the file // needing folksonomy tags on a periodic tick. Standalone (not in the file
@@ -298,54 +294,50 @@ func run() error {
tagEnricher := tags.NewEnricher(pool, logger.With("component", "tags"), tagSettings) tagEnricher := tags.NewEnricher(pool, logger.With("component", "tags"), tagSettings)
go tags.NewWorker(tagEnricher, logger.With("component", "tags")).Run(ctx) go tags.NewWorker(tagEnricher, logger.With("component", "tags")).Run(ctx)
// Start the GC worker. Runs every 1h and sweeps lifecycle tables // GC worker: hourly, sweeps the tables that have no writer-side close
// that have no writer-side close path or retention policy: // path or retention policy: orphan play_events, stale play_sessions,
// orphan play_events, stale play_sessions, expired // expired scrobble_queue failures, stuck system_playlist_runs and expired
// scrobble_queue failures, stuck system_playlist_runs, expired // password_resets. Each sweep is idempotent.
// password_resets. Each sweep is idempotent — a row that's
// already clean is a no-op. Addresses drift audit findings
// #565 #566 #567 #574 #575 (Scribe parent #552).
gcWorker := gc.NewWorker(pool, logger.With("component", "gc")) gcWorker := gc.NewWorker(pool, logger.With("component", "gc"))
go gcWorker.Run(ctx) go gcWorker.Run(ctx)
// The request reconciler completes approved requests once the library // Live-event bus shared between SSE subscribers (api.Mount) and the
// holds what they asked for (M502 #5520). // background workers that publish. Built before any of them, so they all
// Live-event bus shared between SSE subscribers (api.Mount) and // share one instance.
// background workers that publish (reconciler today; scanner later).
// Constructed before any service that publishes so they all share the
// same instance.
bus := eventbus.New() bus := eventbus.New()
// Scan-run lifecycle events use a package-level setter rather than // Scan-run lifecycle events use a package-level setter rather than
// threading the bus through RunScan + TryStartScan + the Scheduler // threading the bus through RunScan + TryStartScan + the Scheduler
// + every test caller. Per-process singleton, set once at startup. // + every test caller. Per-process singleton, set once at startup.
library.SetEventBus(bus) library.SetEventBus(bus)
// The notifications inbox's one writer (M489), shared by every // The notifications inbox's one writer, shared by every
// background producer started here. The API builds its own over the // background producer started here. The API builds its own over the
// same pool and bus. // same pool and bus.
notifier := notifications.New(pool, bus, logger.With("component", "notifications")) notifier := notifications.New(pool, bus, logger.With("component", "notifications"))
// The request reconciler completes approved requests once the library
// holds what they asked for.
requestReconciler := requests.NewReconciler(pool, logger.With("component", "requests"), bus) requestReconciler := requests.NewReconciler(pool, logger.With("component", "requests"), bus)
requestReconciler.SetNotifier(notifier) requestReconciler.SetNotifier(notifier)
library.SetNotifier(notifier) library.SetNotifier(notifier)
go requestReconciler.Run(ctx) go requestReconciler.Run(ctx)
// Release catalogue (M502 #5519): re-reads discographies older than a // Release catalogue: re-reads discographies older than a
// week from MusicBrainz, a batch an hour, through the process-wide // week from MusicBrainz, a batch an hour, through the process-wide
// MusicBrainz pacer it shares with tag enrichment. // MusicBrainz pacer it shares with tag enrichment.
cat := catalogue.New(pool, nil, logger.With("component", "catalogue")) cat := catalogue.New(pool, nil, logger.With("component", "catalogue"))
go cat.Run(ctx) go cat.Run(ctx)
// The wanted list (M502 #5520): reads followed artists' discographies // The wanted list: reads followed artists' discographies
// daily for new releases and marks wants held once the library has them. // daily for new releases and marks wants held once the library has them.
go wanted.New(pool, cat, logger.With("component", "wanted")).Run(ctx) go wanted.New(pool, cat, logger.With("component", "wanted")).Run(ctx)
// Search and grab (M502 #5521): every ten minutes, searches the // Search and grab: every ten minutes, searches the
// configured indexers for wants due, paced by each source's // configured indexers for wants due, paced by each source's
// searches-per-hour, and hands the best release to the download client // searches-per-hour, and hands the best release to the download client
// when auto-grab is on. // when auto-grab is on.
go wanted.NewSearcher(pool, cat, logger.With("component", "search")).Run(ctx) go wanted.NewSearcher(pool, cat, logger.With("component", "search")).Run(ctx)
// Import with verification (M502 #5522): every minute, finished // Import with verification: every minute, finished
// downloads are checked against their album (AcoustID, lengths, tags) // downloads are checked against their album (AcoustID, lengths, tags)
// and filed into the library with MusicBrainz tags, or refused. // and filed into the library with MusicBrainz tags, or refused.
var printer importer.Printer var printer importer.Printer
@@ -356,13 +348,13 @@ func run() error {
imp.DataDir = cfg.Storage.DataDir imp.DataDir = cfg.Storage.DataDir
go imp.Run(ctx) go imp.Run(ctx)
// Download lifecycle (M502 #5523): every ten minutes, a download that // Download lifecycle: every ten minutes, a download that
// failed or that nobody shares is retried, then given up and its album // failed or that nobody shares is retried, then given up and its album
// searched again; an imported one seeds, then is removed. // searched again; an imported one seeds, then is removed.
go lifecycle.New(pool, logger.With("component", "lifecycle")).Run(ctx) go lifecycle.New(pool, logger.With("component", "lifecycle")).Run(ctx)
// Duplicate resolver (M498, M502): classifies the sweep's groups and, with // Duplicate resolver: classifies the sweep's groups and, with auto-resolve
// the operator's auto-resolve on, merges the copies that are safe to merge. // on, merges the copies that are safe to merge.
// Started after SetNotifier so its summaries reach admins. It asks the // Started after SetNotifier so its summaries reach admins. It asks the
// catalogue for the track list of an album whose wrong slot it must // catalogue for the track list of an album whose wrong slot it must
// decide, a few a pass. See internal/library/duplicate_resolve.go. // decide, a few a pass. See internal/library/duplicate_resolve.go.
@@ -371,15 +363,12 @@ func run() error {
TrackLists: cat.RefreshReleaseGroup, Identify: acoustIDLookup.IdentifyRecordings, TrackLists: cat.RefreshReleaseGroup, Identify: acoustIDLookup.IdentifyRecordings,
}).Run(ctx) }).Run(ctx)
// Missing-file re-acquisition (milestone #290, M502 #5520). Wants back // Missing-file re-acquisition: wants back albums whose files have been
// albums whose files have been gone longer than the grace window; the // gone longer than the grace window, for the search schedule to find.
// search schedule looks for them. Hourly tick — the grace window is // Hourly, because the grace window is measured in hours.
// measured in hours.
// //
// A settings-load failure is logged, not fatal: NewSettingsService always // A settings-load failure is logged, not fatal: NewSettingsService always
// returns a usable service holding the defaults, and running on defaults // returns a usable service holding the defaults.
// is far better than dropping the feature because the database hiccuped
// during boot.
reacqSettings, reacqErr := reacquisition.NewSettingsService( reacqSettings, reacqErr := reacquisition.NewSettingsService(
ctx, pool, logger.With("component", "reacquisition")) ctx, pool, logger.With("component", "reacquisition"))
if reacqErr != nil { if reacqErr != nil {
@@ -387,18 +376,18 @@ func run() error {
} }
go reacquisition.NewSweeper(pool, reacqSettings, logger.With("component", "reacquisition")).Run(ctx) go reacquisition.NewSweeper(pool, reacqSettings, logger.With("component", "reacquisition")).Run(ctx)
// library_changes compactor (#357 follow-up). Daily tick; deletes // library_changes compactor: daily, deletes
// rows older than the configured retention so the change-log table // rows older than the configured retention so the change-log table
// doesn't grow unbounded. Clients that drop offline longer than // doesn't grow unbounded. Clients that drop offline longer than
// retention hit the /api/library/sync 410 fallback and resync. // retention hit the /api/library/sync 410 fallback and resync.
libraryChangesCompactor := syncpkg.NewCompactor(pool, logger.With("component", "library_changes_compactor")) libraryChangesCompactor := syncpkg.NewCompactor(pool, logger.With("component", "library_changes_compactor"))
go libraryChangesCompactor.Run(ctx) go libraryChangesCompactor.Run(ctx)
// Notifications inbox retention (M489). Daily: read rows go after 90 // Notifications inbox retention, daily: read rows go after 90
// days, anything at all after a year. // days, anything at all after a year.
go notifications.NewRetention(pool, logger.With("component", "notifications_retention")).Run(ctx) go notifications.NewRetention(pool, logger.With("component", "notifications_retention")).Run(ctx)
// Notifications by email (M489 #5346), grouped, never one per event: new // Notifications by email, grouped, never one per event: new
// music as a daily summary at a local hour, everything else batched an // music as a daily summary at a local hour, everything else batched an
// hour (admin-configurable) after the first item. // hour (admin-configurable) after the first item.
go notifications.NewDigest( go notifications.NewDigest(
@@ -407,10 +396,8 @@ func run() error {
logger.With("component", "notification_digest"), logger.With("component", "notification_digest"),
).Run(ctx) ).Run(ctx)
// Per-user system-playlist scheduler (#392 Half B). Fires each // Per-user system-playlist scheduler: runs each active user's daily
// active user's daily build at 03:00 in their stored timezone. // build at 03:00 in their stored timezone.
// Replaces the 24h-anchored cron loop (removed in the next commit
// of this arc).
playlistScheduler, err := playlists.NewScheduler( playlistScheduler, err := playlists.NewScheduler(
pool, pool,
logger.With("component", "playlist_scheduler"), logger.With("component", "playlist_scheduler"),
@@ -439,7 +426,7 @@ func run() error {
scanCfg := library.RunScanConfig{ scanCfg := library.RunScanConfig{
BackfillCap: 5000, BackfillCap: 5000,
EnrichCap: -1, // #388: no global cap; per-provider rate limits EnrichCap: -1, // no global cap; each provider paces itself
ArtistEnrichCap: -1, ArtistEnrichCap: -1,
DataDir: cfg.Storage.DataDir, DataDir: cfg.Storage.DataDir,
} }
@@ -468,7 +455,7 @@ func run() error {
srv.LoudnessSettings = loudSettings srv.LoudnessSettings = loudSettings
srv.AcoustIDLookup = acoustIDLookup srv.AcoustIDLookup = acoustIDLookup
// The sweeper above holds this same instance, so a save from the admin // The sweeper above holds this same instance, so a save from the admin
// card changes what it does on its next tick (#3936). // card changes what it does on its next tick.
srv.ReacqSettings = reacqSettings srv.ReacqSettings = reacqSettings
srv.StreamSecret = cfg.StreamSecret srv.StreamSecret = cfg.StreamSecret
httpServer := &http.Server{ httpServer := &http.Server{
+5 -6
View File
@@ -1,13 +1,13 @@
// Package acoustid looks up MusicBrainz recording ids by acoustic fingerprint // Package acoustid looks up MusicBrainz recording ids by acoustic fingerprint
// through the AcoustID web service (M401). // through the AcoustID web service.
// //
// What it relies on, from https://acoustid.org/webservice (read 2026-10-06): // What it relies on, from https://acoustid.org/webservice:
// - POST https://api.acoustid.org/v2/lookup with client, duration (whole // - POST https://api.acoustid.org/v2/lookup with client, duration (whole
// seconds), fingerprint (fpcalc's compressed string) and meta. A gzip body // seconds), fingerprint (fpcalc's compressed string) and meta. A gzip body
// with Content-Encoding: gzip is accepted and preferred, because // with Content-Encoding: gzip is accepted and preferred, because
// fingerprints are long. // fingerprints are long.
// - meta=recordings returns each recording's title, duration and artists, // - meta=recordings returns each recording's title, duration and artists,
// which the worker needs to tell several candidates apart (M401 D5). // which the worker needs to tell several candidates apart.
// - "Do not make more than 3 requests per second." // - "Do not make more than 3 requests per second."
// - The service is free for non-commercial use only. // - The service is free for non-commercial use only.
// //
@@ -39,8 +39,7 @@ const (
// minInterval keeps to AcoustID's 3 requests a second with headroom: one // minInterval keeps to AcoustID's 3 requests a second with headroom: one
// every 400ms is 2.5 a second. // every 400ms is 2.5 a second.
minInterval = 400 * time.Millisecond minInterval = 400 * time.Millisecond
// lookupTimeout bounds one request, connection included (rule 156). A // lookupTimeout bounds one request, connection included. A lookup answers in well under a second; 20s is the line past which slow
// lookup answers in well under a second; 20s is the line past which slow
// has become never. // has become never.
lookupTimeout = 20 * time.Second lookupTimeout = 20 * time.Second
// bodyLimit caps the response read. A lookup with meta=recordings for a // bodyLimit caps the response read. A lookup with meta=recordings for a
@@ -59,7 +58,7 @@ const (
) )
var ( var (
// ErrInvalidKey: the operator's application key was refused. No lookup // ErrInvalidKey: the configured application key was refused. No lookup
// can succeed until it is changed, so the worker stops and says so. // can succeed until it is changed, so the worker stops and says so.
ErrInvalidKey = errors.New("acoustid: the API key was refused") ErrInvalidKey = errors.New("acoustid: the API key was refused")
// ErrInvalidFingerprint: AcoustID rejected this track's fingerprint. A // ErrInvalidFingerprint: AcoustID rejected this track's fingerprint. A
+1 -2
View File
@@ -10,8 +10,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer" "git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
) )
// Searching every source at once (M502 #5521), ported from Fabled Librarian // Searching every source at once. A source that is down narrows a search rather than failing it: the
// (#4182). A source that is down narrows a search rather than failing it: the
// results come back with the list of who did not answer. // results come back with the list of who did not answer.
// SourceFailure is one source that could not be reached. // SourceFailure is one source that could not be reached.
+7 -8
View File
@@ -16,12 +16,11 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/release" "git.fabledsword.com/bvandeusen/minstrel/internal/release"
) )
// Taking a release (M502 #5521), ported from Fabled Librarian's grab.go and // Taking a release. One live grab per want: a second is tie-broken against
// rivals.go. One live grab per want: a second is tie-broken against the // the download already running.
// download already running (FL #4470).
// Tag goes on every torrent Minstrel adds, so the operator can tell them // Tag goes on every torrent Minstrel adds, so they can be told apart from
// from their own in the client. // anything else in the client.
const Tag = "minstrel" const Tag = "minstrel"
// MissingClient is Taken.Missing when no download client is set up. // MissingClient is Taken.Missing when no download client is set up.
@@ -211,9 +210,9 @@ func (s *Service) Grab(ctx context.Context, want Want, chosen *Candidate, all []
} }
// heldElsewhere reports whether a release the client says it already holds is // heldElsewhere reports whether a release the client says it already holds is
// one it does not list for Minstrel: held under another category, from before // one it does not list for Minstrel: held under another category. Recorded
// Minstrel took the downloads over (#6176). Recorded as a grab, it would read // as a grab, it would read as gone half an hour later and be taken again,
// as gone half an hour later and be taken again, every pass. A client that // every pass. A client that
// cannot answer now is given the benefit of the doubt, as the add was. // cannot answer now is given the benefit of the doubt, as the add was.
func (s *Service) heldElsewhere(ctx context.Context, hash string) bool { func (s *Service) heldElsewhere(ctx context.Context, hash string) bool {
if hash == "" { if hash == "" {
+3 -3
View File
@@ -20,13 +20,13 @@ import (
const ( const (
BlockOperator = "operator" BlockOperator = "operator"
BlockFiles = "files" BlockFiles = "files"
// BlockImport: the import found its files were not the album (#5522). // BlockImport: the import found its files were not the album.
BlockImport = "import" BlockImport = "import"
// BlockStalled: given up on because it would not finish (#5523); held // BlockStalled: given up on because it would not finish; held
// back until the block's expires_at. // back until the block's expires_at.
BlockStalled = "stalled" BlockStalled = "stalled"
// BlockGone: its download client would not let Minstrel follow it, held // BlockGone: its download client would not let Minstrel follow it, held
// outside Minstrel's category or leaving the client twice (#6176); held // outside Minstrel's category or leaving the client twice; held
// back for GoneBlock. // back for GoneBlock.
BlockGone = "gone" BlockGone = "gone"
) )
+2 -3
View File
@@ -8,8 +8,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer" "git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
) )
// SearchPace is the one Pacer every search shares, ported from Fabled // SearchPace is the one Pacer every search shares. Process-wide because a Fanout is built fresh for each
// Librarian (#4448). Process-wide because a Fanout is built fresh for each
// search; a Pacer per Fanout would start empty and pace nothing. // search; a Pacer per Fanout would start empty and pace nothing.
var SearchPace = &Pacer{} var SearchPace = &Pacer{}
@@ -19,7 +18,7 @@ const holdWithoutRetryAfter = 10 * time.Minute
// Pacer spaces the searches sent to each source by its searches_per_hour: a // Pacer spaces the searches sent to each source by its searches_per_hour: a
// spacing, not a burst bucket, because Prowlarr answers 429 to a few requests // spacing, not a burst bucket, because Prowlarr answers 429 to a few requests
// in quick succession (FL #4304). Every search spends, the schedule's and a // in quick succession. Every search spends, the schedule's and a
// person's alike; only the schedule waits. // person's alike; only the schedule waits.
type Pacer struct { type Pacer struct {
// Now and Sleep are the clock, replaceable in tests. // Now and Sleep are the clock, replaceable in tests.
+4 -4
View File
@@ -12,8 +12,8 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/release" "git.fabledsword.com/bvandeusen/minstrel/internal/release"
) )
// One search for one want, and what could be done about each result (M502 // One search for one want, and what could be done about each result.
// #5521), ported from Fabled Librarian's internal/acquire. internal/release // internal/release
// says whether a name is the album and what quality it is; this package // says whether a name is the album and what quality it is; this package
// asks in the right order and says why in words a person can act on. // asks in the right order and says why in words a person can act on.
@@ -51,7 +51,7 @@ const (
CodeNoSeeders = "no_seeders" CodeNoSeeders = "no_seeders"
CodeDoubt = "doubt" CodeDoubt = "doubt"
// CodeNotBetter: the album, but no better than the copy the library // CodeNotBetter: the album, but no better than the copy the library
// holds, for a want that is an upgrade (#5524). // holds, for a want that is an upgrade.
CodeNotBetter = "not_better" CodeNotBetter = "not_better"
) )
@@ -68,7 +68,7 @@ type Want struct {
ID pgtype.UUID ID pgtype.UUID
release.Want release.Want
// Floor is the tier of the copy the library holds, for an album wanted // Floor is the tier of the copy the library holds, for an album wanted
// for a better one (#5524): a release must rank above it. "" for an // for a better one: a release must rank above it. "" for an
// album not held, and for a video rip, which any clean copy beats. // album not held, and for a video rip, which any clean copy beats.
Floor release.Tier Floor release.Tier
// Held says the floor in words: "MP3, about 192 kbps". // Held says the floor in words: "MP3, about 192 kbps".
+1 -1
View File
@@ -219,7 +219,7 @@ func TestGrab_AClientThatAlreadyHasItCounts(t *testing.T) {
} }
// A duplicate the client does not list for Minstrel sits under another // A duplicate the client does not list for Minstrel sits under another
// category, where it would read as gone half an hour later (#6176). It is set // category, where it would read as gone half an hour later. It is set
// aside rather than taken; forced, it is refused with the client's reason. // aside rather than taken; forced, it is refused with the client's reason.
func TestGrab_ADuplicateHeldOutsideMinstrelIsNotTaken(t *testing.T) { func TestGrab_ADuplicateHeldOutsideMinstrelIsNotTaken(t *testing.T) {
client := &fakeClient{addErr: fmt.Errorf("%w: already downloading Ohio Players (lidarr)", downloader.ErrDuplicate), elsewhere: true} client := &fakeClient{addErr: fmt.Errorf("%w: already downloading Ohio Players (lidarr)", downloader.ErrDuplicate), elsewhere: true}
+4 -5
View File
@@ -1,6 +1,5 @@
// Package acquire turns the operator's configured indexers and download // Package acquire turns the configured indexers and download clients into
// clients into working clients (M502 #5518). Ported from Fabled Librarian's // working clients.
// internal/acquire.
// //
// ONE BUILDER PER KIND. SourceFor makes a search source from a row and // ONE BUILDER PER KIND. SourceFor makes a search source from a row and
// DownloadClient makes a download client, path mapping included. The grab, // DownloadClient makes a download client, path mapping included. The grab,
@@ -60,7 +59,7 @@ type addable struct {
perHour int32 perHour int32
} }
// catalogue is every protocol the operator can add. Exchange starts unpaced // catalogue is every protocol an admin can add. Exchange starts unpaced
// (it searches its own database); the others at 120 searches an hour, which // (it searches its own database); the others at 120 searches an hour, which
// keeps a Prowlarr's indexers under the rate most trackers allow. // keeps a Prowlarr's indexers under the rate most trackers allow.
var catalogue = map[string]addable{ var catalogue = map[string]addable{
@@ -70,7 +69,7 @@ var catalogue = map[string]addable{
ProtocolExchange: {kind: KindDownload, label: "FabledExchange", category: DefaultCategory, timeoutMs: 30000, perHour: 0}, ProtocolExchange: {kind: KindDownload, label: "FabledExchange", category: DefaultCategory, timeoutMs: 30000, perHour: 0},
} }
// Addable says whether protocol is one the operator can add. // Addable says whether protocol is one an admin can add.
func Addable(protocol string) bool { func Addable(protocol string) bool {
_, ok := catalogue[protocol] _, ok := catalogue[protocol]
return ok return ok
+1 -1
View File
@@ -19,7 +19,7 @@ var ErrNotFound = errors.New("no such service")
// ErrTooMany is a twenty-first row of one protocol. // ErrTooMany is a twenty-first row of one protocol.
var ErrTooMany = errors.New("there are already twenty of these") var ErrTooMany = errors.New("there are already twenty of these")
// InvalidError is a setting the operator has to change, in words for the // InvalidError is a setting an admin has to change, in words for the
// settings card. // settings card.
type InvalidError struct { type InvalidError struct {
Field string Field string
+5 -7
View File
@@ -49,25 +49,23 @@ const (
ActionForgotPasswordInit Action = "forgot_password_initiated" ActionForgotPasswordInit Action = "forgot_password_initiated"
ActionPasswordResetByEmail Action = "password_reset_via_email" ActionPasswordResetByEmail Action = "password_reset_via_email"
// Active-sessions surface (#370). Worth auditing rather than silent: // Revoking sessions is what a user does when they think an account is
// revoking sessions is what a user does when they think an account is // compromised, so it is audited: the trail matters most exactly then.
// compromised, so the audit trail is most useful precisely when it's
// exercised.
ActionSessionRevoke Action = "session_revoke" ActionSessionRevoke Action = "session_revoke"
ActionSessionRevokeOthers Action = "session_revoke_others" ActionSessionRevokeOthers Action = "session_revoke_others"
// Duplicate merge (#3911). Irreversible: a copy's file and row are removed // Duplicate merge. Irreversible: a copy's file and row are removed
// and its history moved onto the copy kept. The metadata names both, so the // and its history moved onto the copy kept. The metadata names both, so the
// log can answer "where did that file go" long after the report is gone. // log can answer "where did that file go" long after the report is gone.
ActionDuplicateMerge Action = "duplicate_merge" ActionDuplicateMerge Action = "duplicate_merge"
// The resolver settling a group without a merge (M498, 2026-10-19): // The resolver settling a group without a merge:
// dismissing two versions of a song that are not duplicates, and removing // dismissing two versions of a song that are not duplicates, and removing
// copies that filled another song's track slot (their tracks are kept, // copies that filled another song's track slot (their tracks are kept,
// marked missing, and fetched again). // marked missing, and fetched again).
ActionDuplicateDismiss Action = "duplicate_dismiss" ActionDuplicateDismiss Action = "duplicate_dismiss"
ActionDuplicateWrongFile Action = "duplicate_wrong_file" ActionDuplicateWrongFile Action = "duplicate_wrong_file"
// Subsonic password (#5026): generated in Settings for t/s-only clients. // Subsonic password: generated in Settings for t/s-only clients.
ActionSubsonicPasswordSet Action = "subsonic_password_set" ActionSubsonicPasswordSet Action = "subsonic_password_set"
ActionSubsonicPasswordClear Action = "subsonic_password_clear" ActionSubsonicPasswordClear Action = "subsonic_password_clear"
) )
+12 -13
View File
@@ -7,7 +7,7 @@ import (
) )
// ClientIP returns the caller's address, reading through trustedProxyHops // ClientIP returns the caller's address, reading through trustedProxyHops
// reverse proxies (#2453). // reverse proxies.
// //
// X-Forwarded-For grows left-to-right: every proxy APPENDS the peer it // X-Forwarded-For grows left-to-right: every proxy APPENDS the peer it
// received the request from. For client -> CDN -> own-proxy -> Minstrel the // received the request from. For client -> CDN -> own-proxy -> Minstrel the
@@ -20,13 +20,12 @@ import (
// honestly claim is the address it told us about // honestly claim is the address it told us about
// hops 2 -> XFF[0] = client // hops 2 -> XFF[0] = client
// //
// This replaces an earlier heuristic that ignored XFF whenever RemoteAddr was // Ignoring XFF whenever RemoteAddr is public would be safe but useless: behind
// public. That was safe but useless in the deployment that matters: a proxy // a proxy on a public address (a separate host, or a CDN) every session would
// on a public address (separate host, or a CDN) meant every session recorded // record the proxy, and the active-sessions list could never show an address
// the proxy, so the active-sessions surface could never show an address // change.
// change (#370).
// //
// # What the operator is asserting // # What the hop count asserts
// //
// hops >= 1 is a DECLARATION that a proxy sits in front. Two ways to get it // hops >= 1 is a DECLARATION that a proxy sits in front. Two ways to get it
// wrong, both worth understanding rather than papering over: // wrong, both worth understanding rather than papering over:
@@ -36,9 +35,9 @@ import (
// - Set HIGHER than the real chain: the index runs past the proxy-written // - Set HIGHER than the real chain: the index runs past the proxy-written
// entries into attacker-supplied ones, same result. // entries into attacker-supplied ones, same result.
// //
// Both are inherent to the trusted-hop model — Rails, Caddy, Traefik and // Both are inherent to the trusted-hop model (Rails, Caddy, Traefik and nginx
// nginx all behave this way — which is why 0 is a first-class value and the // behave the same way), which is why 0 is the default and the admin card asks
// admin card tells the operator to count their proxies. // for the number of proxies in front.
func ClientIP(r *http.Request, trustedProxyHops int) string { func ClientIP(r *http.Request, trustedProxyHops int) string {
remote := hostOf(r.RemoteAddr) remote := hostOf(r.RemoteAddr)
if trustedProxyHops <= 0 { if trustedProxyHops <= 0 {
@@ -51,7 +50,7 @@ func ClientIP(r *http.Request, trustedProxyHops int) string {
return remote return remote
} }
// Clamp rather than reject: a chain shorter than the configured depth // Clamp rather than reject: a chain shorter than the configured depth
// means the operator over-counted, and the leftmost entry is the closest // means the hops were over-counted, and the leftmost entry is the closest
// thing to a client on offer. The caveat above covers the risk. // thing to a client on offer. The caveat above covers the risk.
idx := len(chain) - trustedProxyHops idx := len(chain) - trustedProxyHops
if idx < 0 { if idx < 0 {
@@ -68,8 +67,8 @@ func ClientIP(r *http.Request, trustedProxyHops int) string {
// IsHTTPS reports whether the client reached us over HTTPS, reading through // IsHTTPS reports whether the client reached us over HTTPS, reading through
// trustedProxyHops reverse proxies by the same rule as ClientIP. // trustedProxyHops reverse proxies by the same rule as ClientIP.
// //
// TLS terminates at the operator's proxy (rule 94), so r.TLS is nil in every // TLS terminates at the reverse proxy, so r.TLS is nil in every real
// real deployment and only X-Forwarded-Proto can say what the client used. // deployment and only X-Forwarded-Proto can say what the client used.
// That header is believed only when hops >= 1: with no trusted proxy it is // That header is believed only when hops >= 1: with no trusted proxy it is
// just something any client can send. Proxies that append rather than // just something any client can send. Proxies that append rather than
// overwrite produce a comma list, read positionally like X-Forwarded-For. // overwrite produce a comma list, read positionally like X-Forwarded-For.
+3 -3
View File
@@ -68,8 +68,8 @@ func TestClientIP(t *testing.T) {
want: "198.51.100.7", want: "198.51.100.7",
}, },
{ {
// A client prepending a lie is only reachable if the operator // A client prepending a lie is only reachable if the hops are
// over-counts their proxies; at the correct depth it's skipped. // over-counted; at the correct depth it's skipped.
name: "forged prefix is not reached at the correct depth", name: "forged prefix is not reached at the correct depth",
hops: 1, hops: 1,
remoteAddr: "172.18.0.1:40000", remoteAddr: "172.18.0.1:40000",
@@ -178,7 +178,7 @@ func TestIsHTTPS(t *testing.T) {
tls bool tls bool
want bool want bool
}{ }{
// The three cases rule 94 names. // No trusted proxy, a trusted proxy saying https, and one saying http.
{name: "hops 0 ignores a forwarded https", hops: 0, proto: "https", want: false}, {name: "hops 0 ignores a forwarded https", hops: 0, proto: "https", want: false},
{name: "hops 1 believes a forwarded https", hops: 1, proto: "https", want: true}, {name: "hops 1 believes a forwarded https", hops: 1, proto: "https", want: true},
{name: "plain request is not https", hops: 1, want: false}, {name: "plain request is not https", hops: 1, want: false},
+3 -3
View File
@@ -68,8 +68,8 @@ const SessionCookieName = "minstrel_session"
// leak whether the username existed (matches the /rest/* auth posture). // leak whether the username existed (matches the /rest/* auth posture).
// //
// trustedHops supplies the reverse-proxy depth used to record the session's // trustedHops supplies the reverse-proxy depth used to record the session's
// current address (#2453). It's a func rather than an int because the value // current address. It's a func rather than an int because the value
// is operator-editable at runtime and this middleware is constructed once at // is editable at runtime and this middleware is constructed once at
// boot — reading it per request is what makes an admin change take effect // boot — reading it per request is what makes an admin change take effect
// without a restart. Passing nil means "trust nothing", i.e. the socket peer. // without a restart. Passing nil means "trust nothing", i.e. the socket peer.
func RequireUser(pool *pgxpool.Pool, trustedHops func() int) func(http.Handler) http.Handler { func RequireUser(pool *pgxpool.Pool, trustedHops func() int) func(http.Handler) http.Handler {
@@ -155,7 +155,7 @@ func SessionIDCtxKeyForTest() any { return sessionIDCtxKey }
// //
// Database lookup failures fall through too — a transient DB blip should not // Database lookup failures fall through too — a transient DB blip should not
// 5xx a stream request that may have a perfectly valid signed token. The // 5xx a stream request that may have a perfectly valid signed token. The
// error is logged so the operator can correlate. // error is logged so it can be correlated.
func OptionalUser(pool *pgxpool.Pool, logger *slog.Logger) func(http.Handler) http.Handler { func OptionalUser(pool *pgxpool.Pool, logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+4 -4
View File
@@ -7,10 +7,10 @@ import (
) )
// SetupToken guards the first-admin registration. Until the first account // SetupToken guards the first-admin registration. Until the first account
// exists, register makes whoever calls it the admin, so a fresh instance on a // exists, register makes whoever calls it the admin, so without this a fresh
// public address belonged to whoever found it first. Now that call also has // instance on a public address would belong to whoever found it first. The
// to carry this token, which is generated at startup and written only to the // call must carry this token, which is generated at startup and written only
// server log: proof that the caller can read the operator's logs. // to the server log: proof that the caller can read the server's logs.
// //
// The token lives in memory. A restart mints a new one and logs it again, // The token lives in memory. A restart mints a new one and logs it again,
// which is the behaviour wanted: an old token from a log line someone else // which is the behaviour wanted: an old token from a log line someone else
+4 -6
View File
@@ -14,20 +14,18 @@ import "time"
// release.yml passes it through the MINSTREL_VERSION build-arg → Dockerfile // release.yml passes it through the MINSTREL_VERSION build-arg → Dockerfile
// ldflag. // ldflag.
// //
// This carried the literal strings "main" and "dev" until 2026-09-10, which // Image tags carry no version, so this is the only thing that says which
// made every image on a channel report the same thing forever. It stopped // build is running.
// being cosmetic when :vYYYY.MM.DD.HHMM image tags were retired (family rule
// 145): this is now the ONLY thing that says which build is running.
var Version = "dev" var Version = "dev"
// Channel is which line this build came off — "stable" or "dev", or "local" // Channel is which line this build came off — "stable" or "dev", or "local"
// for a plain `docker build`. // for a plain `docker build`.
// //
// A SIBLING FIELD, never a suffix inside Version (family rule 149). The same // A sibling field, never a suffix inside Version. The same
// commit built on both lanes reports the same version and differs only here; // commit built on both lanes reports the same version and differs only here;
// folding the two together is what makes a version string stop being // folding the two together is what makes a version string stop being
// comparable. // comparable.
var Channel = "local" var Channel = "local"
// Started is when this process started, for the Overview's uptime (M504). // Started is when this process started, for the admin Overview's uptime.
var Started = time.Now() var Started = time.Now()
+3 -3
View File
@@ -17,7 +17,7 @@ import (
// the sources are asked again. // the sources are asked again.
const NoArtRetry = 7 * 24 * time.Hour const NoArtRetry = 7 * 24 * time.Hour
// fetchTimeout bounds one look-up across every source (rule 156). // fetchTimeout bounds one look-up across every source.
const fetchTimeout = 30 * time.Second const fetchTimeout = 30 * time.Second
// ErrNoArt: no enabled source has an image, or the sources could not be // ErrNoArt: no enabled source has an image, or the sources could not be
@@ -32,7 +32,7 @@ func ValidMBID(s string) bool { return mbidPattern.MatchString(s) }
// Art serves images for artists and release groups that are not in the // Art serves images for artists and release groups that are not in the
// library (search results, Discover suggestions, wanted albums), from the // library (search results, Discover suggestions, wanted albums), from the
// operator's enabled cover-art sources. Each image is fetched once and kept // enabled cover-art sources. Each image is fetched once and kept
// under the data directory, so the browser only ever talks to Minstrel. // under the data directory, so the browser only ever talks to Minstrel.
type Art struct { type Art struct {
dir string dir string
@@ -51,7 +51,7 @@ type artCall struct {
} }
// ArtSources lists the image sources to ask, in order. The cover-art // ArtSources lists the image sources to ask, in order. The cover-art
// settings service is one: it answers with what the operator has enabled. // settings service is one: it answers with the sources an admin enabled.
type ArtSources interface { type ArtSources interface {
EnabledArtistProviders() []coverart.ArtistArtProvider EnabledArtistProviders() []coverart.ArtistArtProvider
EnabledAlbumProviders() []coverart.AlbumCoverProvider EnabledAlbumProviders() []coverart.AlbumCoverProvider
+1 -1
View File
@@ -1,5 +1,5 @@
// Package catalogue is Minstrel's own record of what artists have released, // Package catalogue is Minstrel's own record of what artists have released,
// read from MusicBrainz and kept in the database (M502 #5519). The wanted // read from MusicBrainz and kept in the database. The wanted
// list, search and import read it; MusicBrainz is asked only when what is // list, search and import read it; MusicBrainz is asked only when what is
// held is missing or old. // held is missing or old.
package catalogue package catalogue
+12 -22
View File
@@ -24,12 +24,10 @@ type Config struct {
Recommendation RecommendationConfig `yaml:"recommendation"` Recommendation RecommendationConfig `yaml:"recommendation"`
Branding BrandingConfig `yaml:"branding"` Branding BrandingConfig `yaml:"branding"`
// StreamSecret is the base64-decoded HMAC key used to sign UPnP / // StreamSecret is the base64-decoded HMAC key used to sign UPnP /
// Sonos stream URLs (see internal/api/stream_token.go and // Sonos stream URLs (see internal/api/stream_token.go). Sourced from
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md). // MINSTREL_STREAM_SECRET (base64-encoded), with a per-machine fallback
// Sourced from MINSTREL_STREAM_SECRET (base64-encoded), with a // persisted at <Storage.DataDir>/stream_secret. Not read from YAML: it
// per-machine fallback persisted at <Storage.DataDir>/stream_secret. // is per-machine state, not a setting. Never log the contents.
// Not serialized to YAML — operator-machine-scoped runtime state, not
// a user-facing setting. Never log the contents.
StreamSecret []byte `yaml:"-"` StreamSecret []byte `yaml:"-"`
} }
@@ -37,12 +35,8 @@ type ServerConfig struct {
Address string `yaml:"address"` Address string `yaml:"address"`
} }
// StorageConfig points at on-disk locations the server uses for cached // StorageConfig points at the on-disk root for generated files: playlist
// runtime artifacts. Currently: // cover collages, artwork, caches and the stream secret.
// - DataDir hosts <DataDir>/playlist_covers/{playlist_id}.jpg (M7 #352).
//
// More entries may grow here (e.g., transcode cache) without breaking
// existing layouts.
type StorageConfig struct { type StorageConfig struct {
// DataDir is the root for cached artifacts. Default: "./data" (relative // DataDir is the root for cached artifacts. Default: "./data" (relative
// to the working directory). Operators in production should set an // to the working directory). Operators in production should set an
@@ -88,10 +82,9 @@ type EventsConfig struct {
SkipMaxDurationPlayedMs int `yaml:"skip_max_duration_played_ms"` SkipMaxDurationPlayedMs int `yaml:"skip_max_duration_played_ms"`
} }
// RecommendationConfig holds the radio path's operational knobs. The // RecommendationConfig holds the radio path's operational knobs. The scoring
// scoring WEIGHTS moved to DB-backed admin settings (#1250, // weights are DB-backed admin settings (internal/recsettings), tuned in the
// internal/recsettings) — YAML is bootstrap-only; anything an operator // UI with live effect.
// tunes lives in the UI with live effect.
type RecommendationConfig struct { type RecommendationConfig struct {
RecentlyPlayedHours int `yaml:"recently_played_hours"` RecentlyPlayedHours int `yaml:"recently_played_hours"`
RadioSize int `yaml:"radio_size"` RadioSize int `yaml:"radio_size"`
@@ -120,8 +113,7 @@ func Default() Config {
}, },
Library: LibraryConfig{ Library: LibraryConfig{
// On by default so a fresh install populates its library on // On by default so a fresh install populates its library on
// first boot without the operator hunting for a "scan now" // first boot with no manual step. Scans are incremental (unchanged files skipped by
// button. Scans are incremental (unchanged files skipped by
// mtime), so every later restart is just a cheap directory walk. // mtime), so every later restart is just a cheap directory walk.
ScanOnStartup: true, ScanOnStartup: true,
CoverArtFromMBCAA: true, CoverArtFromMBCAA: true,
@@ -159,15 +151,13 @@ const streamSecretBytes = 64
const streamSecretFile = "stream_secret" const streamSecretFile = "stream_secret"
// resolveStreamSecret populates cfg.StreamSecret using, in order: // resolveStreamSecret populates cfg.StreamSecret using, in order:
// 1. MINSTREL_STREAM_SECRET env var (operator-supplied, base64-url // 1. MINSTREL_STREAM_SECRET env var (base64-url encoded — accepts either padded or raw form).
// encoded — accepts either padded or raw form).
// 2. <Storage.DataDir>/stream_secret if present (auto-generated on a // 2. <Storage.DataDir>/stream_secret if present (auto-generated on a
// previous boot; survives restarts so signed URLs stay valid). // previous boot; survives restarts so signed URLs stay valid).
// 3. Auto-generate streamSecretBytes random bytes, persist them to that // 3. Auto-generate streamSecretBytes random bytes, persist them to that
// file at 0600, then use them. // file at 0600, then use them.
// //
// Logs (only) when an auto-generation happens so the operator can spot // Logs (only) when an auto-generation happens, so it shows on first boot. Never logs the contents.
// it during first-boot. Never logs the contents.
// //
// The loader runs before slog is initialized in cmd/minstrel/main.go; // The loader runs before slog is initialized in cmd/minstrel/main.go;
// log.Printf is the project's pre-logger convention. // log.Printf is the project's pre-logger convention.
+7 -8
View File
@@ -1,11 +1,10 @@
// Package coplay builds household co-play similarity edges (#1533, milestone // Package coplay builds household co-play similarity edges. Minstrel is a
// #160 Opt 5). Minstrel is a single shared-library, multi-user server (no // single shared-library, multi-user server (no per-user library ACL), so the
// per-user library ACL), so the "household" is the whole instance's user set. // "household" is the whole instance's user set. A periodic worker recomputes
// A periodic worker recomputes artist–artist co-occurrence from play_events // artist–artist co-occurrence from play_events entirely in SQL (no external
// entirely in SQL (no external API) and stores the edges in artist_similarity // API) and stores the edges in artist_similarity under
// under source='user_cooccurrence' — the pre-provisioned co-occurrence source // source='user_cooccurrence', which the radio/mix candidate pool consumes as
// from the 0009 schema — which the radio/mix candidate pool consumes as a // a collaborative arm. Single-user servers produce no edges (the >= 2 co-player
// collaborative arm. Single-user servers produce no edges (the >= 2 co-player
// gate), so the feature is a graceful no-op there. // gate), so the feature is a graceful no-op there.
package coplay package coplay
+5 -5
View File
@@ -141,11 +141,11 @@ func (e *Enricher) recordArtistArt(ctx context.Context, artistID pgtype.UUID, th
// //
// Emits a single Info log line at end-of-batch with a category // Emits a single Info log line at end-of-batch with a category
// breakdown (succeeded / settled-none / no-MBID / left-NULL / // breakdown (succeeded / settled-none / no-MBID / left-NULL /
// errored). The JSON tally written to scan_runs keeps the existing // errored). The JSON tally written to scan_runs keeps the
// processed/succeeded/failed shape for backwards compat with the // processed/succeeded/failed shape the admin overview reads; the log line
// admin overview UI; the log line is the operator's diagnostic. // is the diagnostic.
// limit: 0 = stage disabled; >0 = bounded; <0 = unbounded (#388 — // limit: 0 = stage disabled; >0 = bounded; <0 = unbounded (remote
// no global cap; remote providers self-throttle per provider). // providers pace themselves).
func (e *Enricher) EnrichArtistBatch(ctx context.Context, limit int, dataDir string, func (e *Enricher) EnrichArtistBatch(ctx context.Context, limit int, dataDir string,
progressCb func(processed, succeeded, failed int)) (processed, succeeded, failed int, err error) { progressCb func(processed, succeeded, failed int)) (processed, succeeded, failed int, err error) {
if limit == 0 { if limit == 0 {
+8 -10
View File
@@ -210,13 +210,12 @@ func (e *Enricher) recordAlbumCover(ctx context.Context, albumID pgtype.UUID, pa
// //
// Emits a single Info log line at end-of-batch with a category // Emits a single Info log line at end-of-batch with a category
// breakdown (succeeded / settled-none / left-NULL / errored). The // breakdown (succeeded / settled-none / left-NULL / errored). The
// JSON tally written to scan_runs keeps the existing // JSON tally written to scan_runs keeps the processed/succeeded/failed
// processed/succeeded/failed shape for backwards compat with the // shape the admin overview reads; the log line is the diagnostic.
// admin overview UI; the log line is the operator's diagnostic.
// limit semantics: 0 = stage disabled (skip); >0 = bounded batch; // limit semantics: 0 = stage disabled (skip); >0 = bounded batch;
// <0 = unbounded — walk every album needing art. The global cap was // <0 = unbounded — walk every album needing art. External providers pace
// removed (#388); external providers self-throttle via their // themselves via their httpClient MinInterval; local sources run at disk
// per-provider httpClient MinInterval, local sources run at disk speed. // speed.
func (e *Enricher) EnrichBatch(ctx context.Context, limit int, func (e *Enricher) EnrichBatch(ctx context.Context, limit int,
progressCb func(processed, succeeded, failed int)) (processed, succeeded, failed int, err error) { progressCb func(processed, succeeded, failed int)) (processed, succeeded, failed int, err error) {
if limit == 0 { if limit == 0 {
@@ -302,7 +301,7 @@ func (e *Enricher) logBatchSummary(stage string, eligible, processed, succeeded,
// admin bulk-retry endpoint. Clears 'none' to NULL first so // admin bulk-retry endpoint. Clears 'none' to NULL first so
// EnrichAlbum picks them up. // EnrichAlbum picks them up.
// limit: 0 = no-op; >0 = bounded; <0 = unbounded (retry every // limit: 0 = no-op; >0 = bounded; <0 = unbounded (retry every
// missing/none album). Admin bulk-retry passes <0 post-#388. // missing/none album). Admin bulk-retry passes <0.
func (e *Enricher) EnrichRetryMissing(ctx context.Context, limit int) (int, error) { func (e *Enricher) EnrichRetryMissing(ctx context.Context, limit int) (int, error) {
if limit == 0 { if limit == 0 {
return 0, nil return 0, nil
@@ -346,9 +345,8 @@ func (e *Enricher) RetryAlbum(ctx context.Context, albumID pgtype.UUID) error {
} }
return fmt.Errorf("get album: %w", err) return fmt.Errorf("get album: %w", err)
} }
// Delete the file only if we previously wrote it (mbcaa or // Delete the file only if we wrote it (mbcaa or theaudiodb). Sidecar
// theaudiodb). Operator sidecars stay; embedded extracts (future) // images placed beside the music stay.
// live in a managed cache with its own lifecycle.
if row.CoverArtSource != nil && if row.CoverArtSource != nil &&
(*row.CoverArtSource == "mbcaa" || *row.CoverArtSource == "theaudiodb") && (*row.CoverArtSource == "mbcaa" || *row.CoverArtSource == "theaudiodb") &&
row.CoverArtPath != nil && *row.CoverArtPath != "" { row.CoverArtPath != nil && *row.CoverArtPath != "" {
+3 -4
View File
@@ -16,8 +16,7 @@ import (
type Provider interface { type Provider interface {
// ID is a stable lowercase token persisted in DB columns // ID is a stable lowercase token persisted in DB columns
// (cover_art_source / artist_art_source) and the // (cover_art_source / artist_art_source) and the
// cover_art_provider_settings.provider_id key, plus referenced // cover_art_provider_settings.provider_id key, and shown in the admin UI.
// in the operator-facing UI.
ID() string ID() string
// DisplayName is the human-readable label shown in the admin // DisplayName is the human-readable label shown in the admin
@@ -36,7 +35,7 @@ type Provider interface {
// with this provider on. Both v1 providers return true. // with this provider on. Both v1 providers return true.
DefaultEnabled() bool DefaultEnabled() bool
// Configure receives the operator's settings (api_key, enabled // Configure receives the admin's settings (api_key, enabled
// flag) and applies them. Called at boot during reconciliation // flag) and applies them. Called at boot during reconciliation
// and on every admin PATCH. Implementations must be idempotent // and on every admin PATCH. Implementations must be idempotent
// and goroutine-safe (the enricher may be reading concurrently). // and goroutine-safe (the enricher may be reading concurrently).
@@ -84,7 +83,7 @@ type AlbumCoverProvider interface {
// ReleaseGroupCoverProvider is an opt-in capability for sources that can // ReleaseGroupCoverProvider is an opt-in capability for sources that can
// answer by MusicBrainz release group, which is how the release catalogue // answer by MusicBrainz release group, which is how the release catalogue
// names an album before any edition of it is held (M502 #5519). // names an album before any edition of it is held.
type ReleaseGroupCoverProvider interface { type ReleaseGroupCoverProvider interface {
AlbumCoverProvider AlbumCoverProvider
FetchReleaseGroupCover(ctx context.Context, groupMBID string) ([]byte, error) FetchReleaseGroupCover(ctx context.Context, groupMBID string) ([]byte, error)
+2 -2
View File
@@ -11,7 +11,7 @@ import (
"time" "time"
) )
// FetcherConfig assembles the operator-tunable knobs for the MBCAA // FetcherConfig assembles the tunable knobs for the MBCAA
// HTTP client. Kept exported because main.go's boot wiring constructs // HTTP client. Kept exported because main.go's boot wiring constructs
// one to pass into NewMBCAAProviderFromConfig. // one to pass into NewMBCAAProviderFromConfig.
type FetcherConfig struct { type FetcherConfig struct {
@@ -97,7 +97,7 @@ func (p *mbcaaProvider) FetchAlbumCover(ctx context.Context, ref AlbumRef) ([]by
// FetchReleaseGroupCover retrieves the 500px front cover the Cover Art // FetchReleaseGroupCover retrieves the 500px front cover the Cover Art
// Archive chose for a release group: what a search result or a wanted album // Archive chose for a release group: what a search result or a wanted album
// shows before any edition of it is held (M502 #5519). // shows before any edition of it is held.
func (p *mbcaaProvider) FetchReleaseGroupCover(ctx context.Context, groupMBID string) ([]byte, error) { func (p *mbcaaProvider) FetchReleaseGroupCover(ctx context.Context, groupMBID string) ([]byte, error) {
return p.fetchFront(ctx, "release-group", groupMBID) return p.fetchFront(ctx, "release-group", groupMBID)
} }
+4 -5
View File
@@ -270,10 +270,9 @@ func enabledSetSignature(enabledIDs map[string]bool) string {
// unchanged, returns (currentVersion, false, nil) without touching the DB. // unchanged, returns (currentVersion, false, nil) without touching the DB.
// //
// Used at boot to ensure rows previously settled to 'none' get re-tried // Used at boot to ensure rows previously settled to 'none' get re-tried
// once after a provider chain change (e.g. operator updates the binary // once after a provider chain change (an update registers a new provider,
// and a new provider is registered, or one is removed). The version // or removes one). The version bump makes 'none' rows eligible on the next
// bump triggers eligibility for 'none' rows on the next enrichment pass // enrichment pass with no manual step.
// without requiring a manual operator action.
func (s *SettingsService) BumpVersionIfProvidersChanged(ctx context.Context) (version int32, bumped bool, err error) { func (s *SettingsService) BumpVersionIfProvidersChanged(ctx context.Context) (version int32, bumped bool, err error) {
hash := registeredProvidersHash() hash := registeredProvidersHash()
q := dbq.New(s.pool) q := dbq.New(s.pool)
@@ -313,7 +312,7 @@ func registeredProvidersHash() string {
// BumpVersion unconditionally increments cover_art_sources_meta.current_version // BumpVersion unconditionally increments cover_art_sources_meta.current_version
// and returns the new value. Used by the manual "Re-search missing art" // and returns the new value. Used by the manual "Re-search missing art"
// admin endpoint when the operator wants every 'none' row re-attempted // admin endpoint, to re-attempt every 'none' row
// on the next enrichment pass. // on the next enrichment pass.
// //
// Distinct from BumpVersionIfProvidersChanged: that method is the boot- // Distinct from BumpVersionIfProvidersChanged: that method is the boot-
+3 -4
View File
@@ -1,6 +1,5 @@
// Package downloader drives the download clients Minstrel hands a grab to // Package downloader drives the download clients Minstrel hands a grab to:
// (M502 #5518): qBittorrent's WebUI v2, and FabledExchange's native // qBittorrent's WebUI v2, and FabledExchange's native /api/downloads.
// /api/downloads. Ported from Fabled Librarian's internal/downloader.
// //
// Both answer through one Driver, and Torrent is qBittorrent's shape: the // Both answer through one Driver, and Torrent is qBittorrent's shape: the
// Exchange driver maps its own downloads onto it, so the grab, the importer // Exchange driver maps its own downloads onto it, so the grab, the importer
@@ -58,7 +57,7 @@ type Config struct {
// APIKey is Exchange's, sent as a bearer only when set. // APIKey is Exchange's, sent as a bearer only when set.
APIKey string APIKey string
// Category files everything Minstrel adds, so its torrents can be told // Category files everything Minstrel adds, so its torrents can be told
// from the operator's own. // from anything else in the client.
Category string Category string
Timeout time.Duration Timeout time.Duration
} }
+2 -3
View File
@@ -1,7 +1,6 @@
// Package exchange talks to FabledExchange's native API for the two jobs that // Package exchange talks to FabledExchange's native API for the two jobs that
// are not downloading (M502 #5518): searching its DHT index (/api/search) and // are not downloading: searching its DHT index (/api/search) and describing
// describing one torrent (/api/torrents/{infohash}). Downloading is // one torrent (/api/torrents/{infohash}). Downloading is internal/downloader's.
// internal/downloader's. Ported from Fabled Librarian's internal/exchange.
// //
// The native search rather than Exchange's Torznab feed, because it reports // The native search rather than Exchange's Torznab feed, because it reports
// whether a swarm was measured and how many files a torrent holds, which the // whether a swarm was measured and how many files a torrent holds, which the
+13 -15
View File
@@ -1,8 +1,6 @@
// Package gc runs periodic garbage-collection / lifecycle sweeps // Package gc runs periodic garbage-collection / lifecycle sweeps against
// against tables that have NO writer-side close path or NO retention // tables that have no writer-side close path or no retention policy. Each
// policy. Each sweep addresses a drift finding from the 2026-06-02 // sweep is idempotent: re-running it on already-clean rows is a no-op.
// audit (Scribe parent #552) and is idempotent — re-running it on
// already-clean rows is a no-op.
// //
// One Worker handles all sweeps so a single long-tick goroutine // One Worker handles all sweeps so a single long-tick goroutine
// amortises the per-tick fixed cost. Each individual sweep is small // amortises the per-tick fixed cost. Each individual sweep is small
@@ -11,16 +9,16 @@
// visible in the application log without an explicit metrics layer. // visible in the application log without an explicit metrics layer.
// //
// Sweeps: // Sweeps:
// - GcCloseStalePlayEvents (#566) // - GcCloseStalePlayEvents
// - GcClosePlaySessionsWithNoRecentEvents (#565) // - GcClosePlaySessionsWithNoRecentEvents
// - GcExpireScrobbleQueueFailedRows (#567) // - GcExpireScrobbleQueueFailedRows
// - GcResetStuckSystemPlaylistRuns (#574) // - GcResetStuckSystemPlaylistRuns
// - GcDeleteExpiredPasswordResets (#575) // - GcDeleteExpiredPasswordResets
// - GcDeleteExpiredSessions (M462 #4978 — idle 30d / absolute 1y) // - GcDeleteExpiredSessions (idle 30d / absolute 1y)
// - GcPruneDiagnostics (M9 — diagnostics 30d retention) // - GcPruneDiagnostics (30d retention)
// - GcDeleteExpiredSuggestionSnoozes (#2374 — snoozes expire, then go) // - GcDeleteExpiredSuggestionSnoozes (snoozes expire, then go)
// - GcDeleteOrphanedCandidateArtistTags(+State) (#2376 — the similarity // - GcDeleteOrphanedCandidateArtistTags(+State) (the similarity feed
// feed churns, so cached candidate tags outlive their candidates) // churns, so cached candidate tags outlive their candidates)
package gc package gc
import ( import (
+3 -3
View File
@@ -64,11 +64,11 @@ type Importer struct {
Catalogue Catalogue Catalogue Catalogue
Printer Printer Printer Printer
// Roots are the library's folders (the scan paths), read on every use // Roots are the library's folders (the scan paths), read on every use
// because they change in Settings (#6129). New music is filed under the // because they change in Settings. New music is filed under the
// one the settings name, or the first. // one the settings name, or the first.
Roots func() []string Roots func() []string
// DataDir is where cached art lives, for tidying an artist an upgrade // DataDir is where cached art lives, for tidying an artist an upgrade
// leaves with nothing (#5524). Empty skips it. // leaves with nothing. Empty skips it.
DataDir string DataDir string
// ClientFor builds the download client a grab went to. Defaults to // ClientFor builds the download client a grab went to. Defaults to
// acquire.DownloadClientFor. // acquire.DownloadClientFor.
@@ -195,7 +195,7 @@ func (im *Importer) passClient(ctx context.Context, q *dbq.Queries, code string,
} }
t, ok := byHash[indexer.NormalizeHash(g.InfoHash)] t, ok := byHash[indexer.NormalizeHash(g.InfoHash)]
if !ok { if !ok {
continue // gone from the client: the lifecycle settles it (#5523) continue // gone from the client: the lifecycle settles it
} }
if !g.WantID.Valid { if !g.WantID.Valid {
wait(g, "Its album is no longer wanted.") wait(g, "Its album is no longer wanted.")
+1 -1
View File
@@ -26,7 +26,7 @@ type Printer interface {
var ErrNoPrints = errors.New("importer: fingerprints are not available") var ErrNoPrints = errors.New("importer: fingerprints are not available")
// AcoustIDPrinter fingerprints with fpcalc and asks AcoustID, under the // AcoustIDPrinter fingerprints with fpcalc and asks AcoustID, under the
// operator's settings (key, minimum score). Each call reads the settings, so // AcoustID settings (key, minimum score). Each call reads the settings, so
// a change takes effect on the next file. // a change takes effect on the next file.
type AcoustIDPrinter struct { type AcoustIDPrinter struct {
Settings interface { Settings interface {
+1 -1
View File
@@ -21,7 +21,7 @@ type Placement struct {
} }
// Plan names every file's destination under root the way the library is // Plan names every file's destination under root the way the library is
// already named (note #5527), so new arrivals sit beside what is there: // conventionally named, so new arrivals sit beside what is there:
// //
// {Artist}/{Album} ({Year})/{Artist} - {Album} - {track:00} - {Title}.ext // {Artist}/{Album} ({Year})/{Artist} - {Album} - {track:00} - {Title}.ext
// //
+4 -4
View File
@@ -40,9 +40,9 @@ type Tags struct {
// gets the plain names only. // gets the plain names only.
// //
// A Vorbis track or disc number is the number alone, its count in // A Vorbis track or disc number is the number alone, its count in
// TRACKTOTAL and DISCTOTAL, as Picard writes them: dhowden/tag reads // TRACKTOTAL and DISCTOTAL, as Picard writes them: dhowden/tag reads a
// TRACKNUMBER "3/20" as 0, and every album imported before #6171 lost its // TRACKNUMBER of "3/20" as 0, which would lose the album's order. ID3 and MP4
// order that way. ID3 and MP4 take "3/20", which their readers parse. // take "3/20", which their readers parse.
func (t Tags) args(ext string) []string { func (t Tags) args(ext string) []string {
vorbis := ext == ".flac" || ext == ".ogg" || ext == ".opus" vorbis := ext == ".flac" || ext == ".ogg" || ext == ".opus"
track, disc := ofN(t.Track, t.Tracks), ofN(t.Disc, t.Discs) track, disc := ofN(t.Track, t.Tracks), ofN(t.Disc, t.Discs)
@@ -111,7 +111,7 @@ func Place(ctx context.Context, src, dst string, t Tags) (tagged bool, err error
return placeFile(ctx, src, dst, t, false) return placeFile(ctx, src, dst, t, false)
} }
// Overwrite is Place onto a file an upgrade replaces (#5524): the new copy // Overwrite is Place onto a file an upgrade replaces: the new copy
// takes the old one's path in one rename, so the library never sees the // takes the old one's path in one rename, so the library never sees the
// path empty, and the track row at that path, history and all, carries on // path empty, and the track row at that path, history and all, carries on
// with the new file. // with the new file.
+1 -1
View File
@@ -17,7 +17,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/release" "git.fabledsword.com/bvandeusen/minstrel/internal/release"
) )
// Importing an upgrade (M502 #5524): a better copy of an album the library // Importing an upgrade: a better copy of an album the library
// holds. Each file is matched to the track it would replace, by recording // holds. Each file is matched to the track it would replace, by recording
// and then by disc and track. It replaces that track when the track is a // and then by disc and track. It replaces that track when the track is a
// video rip or the new file is a better tier; otherwise the old track stays. // video rip or the new file is a better tier; otherwise the old track stays.
+5 -7
View File
@@ -1,13 +1,11 @@
// Package importer checks a finished download against the album it was // Package importer checks a finished download against the album it was
// grabbed for and files it into the library (M502 #5522). Nothing reaches // grabbed for and files it into the library. Nothing reaches
// the library before the check says the files are the album: a wrong album, // the library before the check says the files are the album: a wrong album,
// another version of it, a video rip or an incomplete copy is refused, its // another version of it, a video rip or an incomplete copy is refused, its
// release is blocked and the album is searched for again. // release is blocked and the album is searched for again.
// //
// Ported from Fabled Librarian's importer and relocate packages, with the // The files are matched to one of the album's MusicBrainz editions by
// check rewritten for music: the files are matched to one of the album's // AcoustID fingerprints, track lengths, titles and the tags they carry.
// MusicBrainz editions by AcoustID fingerprints, track lengths, titles and
// the tags they carry.
package importer package importer
import ( import (
@@ -67,7 +65,7 @@ type File struct {
RecordingMBID string RecordingMBID string
LengthMs int LengthMs int
// Recordings are the MusicBrainz recordings AcoustID says the audio is, // Recordings are the MusicBrainz recordings AcoustID says the audio is,
// at or above the operator's minimum score. // at or above the configured minimum score.
Recordings []string Recordings []string
// Video: the file name says it is a music video, a lyric video or the // Video: the file name says it is a music video, a lyric video or the
// like (library.SourceMarkersFor). // like (library.SourceMarkersFor).
@@ -113,7 +111,7 @@ type Want struct {
Year int Year int
PrimaryType string PrimaryType string
SecondaryTypes []string SecondaryTypes []string
// Upgrade: a held album wanted for a better copy (#5524), quality or // Upgrade: a held album wanted for a better copy, quality or
// rip; AlbumID is the library album it holds, whose tracks the import // rip; AlbumID is the library album it holds, whose tracks the import
// replaces. // replaces.
Upgrade string Upgrade string
+1 -1
View File
@@ -220,7 +220,7 @@ func TestTagsArgs(t *testing.T) {
t.Errorf("flac args lack %q: %s", s, flac) t.Errorf("flac args lack %q: %s", s, flac)
} }
} }
// dhowden/tag reads a Vorbis TRACKNUMBER of "8/13" as 0 (#6171). // dhowden/tag reads a Vorbis TRACKNUMBER of "8/13" as 0.
if strings.Contains(flac, "/13") { if strings.Contains(flac, "/13") {
t.Errorf("flac track number carries its count: %s", flac) t.Errorf("flac track number carries its count: %s", flac)
} }
+4 -5
View File
@@ -1,5 +1,5 @@
// Package indexer is what every search source answers with, and the Torznab // Package indexer is what every search source answers with, and the Torznab
// client (M502 #5518). Ported from Fabled Librarian's internal/indexer. // client.
// //
// A client here holds ONE endpoint and knows nothing about the others. The // A client here holds ONE endpoint and knows nothing about the others. The
// fan-out across sources, the merge and what a source being down means belong // fan-out across sources, the merge and what a source being down means belong
@@ -7,9 +7,8 @@
// about one source. // about one source.
// //
// Prowlarr is not served from here: it has no aggregate Torznab feed (`all` // Prowlarr is not served from here: it has no aggregate Torznab feed (`all`
// 404s like any id it does not have, measured by Fabled Librarian, #4304), so // 404s like any id it does not have), so asking it everything at once means
// asking it everything at once means its native API, which internal/prowlarr // its native API, which internal/prowlarr speaks.
// speaks.
package indexer package indexer
import ( import (
@@ -34,7 +33,7 @@ var ErrUnreachable = errors.New("indexer unreachable")
// field in Settings, an outage is something to wait out. // field in Settings, an outage is something to wait out.
var ErrAuth = errors.New("the API key was refused") var ErrAuth = errors.New("the API key was refused")
// UserAgent names Minstrel in the operator's own Prowlarr and indexer logs. // UserAgent names Minstrel in Prowlarr's and each indexer's logs.
const UserAgent = "Minstrel (self-hosted music server; release search)" const UserAgent = "Minstrel (self-hosted music server; release search)"
// MusicCategories are the Newznab categories a music search asks for: 3000 // MusicCategories are the Newznab categories a music search asks for: 3000
+2 -3
View File
@@ -23,9 +23,8 @@ type TorznabConfig struct {
// FeedURL is the feed's whole address up to and including /api, as // FeedURL is the feed's whole address up to and including /api, as
// Jackett or a Prowlarr indexer shows it — e.g. // Jackett or a Prowlarr indexer shows it — e.g.
// http://prowlarr:9696/12/api. Query parameters are appended to it. // http://prowlarr:9696/12/api. Query parameters are appended to it.
// One field rather than Fabled Librarian's base + path + indexer id: a // One field rather than base + path + indexer id: a pasted URL is more
// pasted URL is more reliable than Minstrel being right about a third // reliable than Minstrel being right about a third party's routes.
// party's routes.
FeedURL string FeedURL string
// APIKey is optional: Torznab makes it so. A feed that needs one says so // APIKey is optional: Torznab makes it so. A feed that needs one says so
// with Newznab error 100, which is ErrAuth. // with Newznab error 100, which is ErrAuth.
+5 -7
View File
@@ -1,6 +1,4 @@
// Package lifecycle looks after a download from the grab to its removal // Package lifecycle looks after a download from the grab to its removal.
// (M502 #5523), ported from Fabled Librarian's internal/health and its seed
// release (System 104).
// //
// - A download the client has failed is given up at once. // - A download the client has failed is given up at once.
// - One nobody has shared, or still fetching its file list, after the // - One nobody has shared, or still fetching its file list, after the
@@ -75,7 +73,7 @@ const (
// goneAfter: a client restarting can answer without a download for a // goneAfter: a client restarting can answer without a download for a
// while; half an hour missing is gone. // while; half an hour missing is gone.
goneAfter = 30 * time.Minute goneAfter = 30 * time.Minute
// goneWindow: a release gone twice within it is held back (#6176). // goneWindow: a release gone twice within it is held back.
goneWindow = 7 * 24 * time.Hour goneWindow = 7 * 24 * time.Hour
) )
@@ -335,7 +333,7 @@ func (s *Service) judge(ctx context.Context, q *dbq.Queries, d downloader.Driver
} }
// The client's own verdict, given up on at once: it has already made // The client's own verdict, given up on at once: it has already made
// the decision a retry would ask about (FL #4749). // the decision a retry would ask about.
if t.Failed() { if t.Failed() {
why := t.FailReason why := t.FailReason
if why == "" { if why == "" {
@@ -445,8 +443,8 @@ func (s *Service) giveUp(ctx context.Context, q *dbq.Queries, d downloader.Drive
// endGone ends a grab whose download left the client, and searches again. // endGone ends a grab whose download left the client, and searches again.
// The first time, the release is not held back: nothing was found wrong with // The first time, the release is not held back: nothing was found wrong with
// it. A release that leaves its client again within goneWindow is held back for // it. A release that leaves its client again within goneWindow is held back for
// acquire.GoneBlock (#6176): whatever keeps the client from listing it, taking // acquire.GoneBlock: whatever keeps the client from listing it would otherwise
// it again on every pass found 38 grabs of one release in a day. // have it taken again on every pass.
func (s *Service) endGone(ctx context.Context, q *dbq.Queries, g dbq.ListLiveGrabsRow, rep *Report) { func (s *Service) endGone(ctx context.Context, q *dbq.Queries, g dbq.ListLiveGrabsRow, rep *Report) {
detail := "The download client no longer lists it: removed outside Minstrel. The album is searched for again." detail := "The download client no longer lists it: removed outside Minstrel. The album is searched for again."
before, err := q.CountGoneGrabs(ctx, dbq.CountGoneGrabsParams{ before, err := q.CountGoneGrabs(ctx, dbq.CountGoneGrabsParams{
+1 -1
View File
@@ -279,7 +279,7 @@ func TestLifecycle_ADownloadGoneFromTheClientEndsAfterHalfAnHour_Integration(t *
} }
// A release that leaves its client a second time is held back, whatever the // A release that leaves its client a second time is held back, whatever the
// cause (#6176): one release was taken 40 times in a day, each ending gone. // cause, so it is not taken again on every pass.
func TestLifecycle_AReleaseGoneTwiceIsHeldBack_Integration(t *testing.T) { func TestLifecycle_AReleaseGoneTwiceIsHeldBack_Integration(t *testing.T) {
st := newStack(t, downloader.Torrent{Name: "Daft Punk - RAM [FLAC]", Progress: 0.3, State: "downloading", NumSeeds: 4}) st := newStack(t, downloader.Torrent{Name: "Daft Punk - RAM [FLAC]", Progress: 0.3, State: "downloading", NumSeeds: 4})
ctx := context.Background() ctx := context.Background()
+1 -1
View File
@@ -23,7 +23,7 @@ func New(w io.Writer, level, format string) (*slog.Logger, error) {
default: default:
return nil, fmt.Errorf("unknown log format %q (want text|json)", format) return nil, fmt.Errorf("unknown log format %q (want text|json)", format)
} }
// Errors are also kept for the admin Overview (M504). // Errors are also kept for the admin Overview.
return slog.New(newRingHandler(h, Errors)), nil return slog.New(newRingHandler(h, Errors)), nil
} }
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"time" "time"
) )
// The server's recent errors, kept in memory for the admin Overview (M504): // The server's recent errors, kept in memory for the admin Overview:
// what has gone wrong since the process started, without reading the // what has gone wrong since the process started, without reading the
// container's logs. Only ERROR and above, and only the newest few; the full // container's logs. Only ERROR and above, and only the newest few; the full
// record is still the log stream. // record is still the log stream.
+2 -2
View File
@@ -1,5 +1,5 @@
// Package mood maps folksonomy tags (from the track_tags cache, #1490) to a // Package mood maps folksonomy tags (from the track_tags cache) to a small
// small set of canonical mood buckets (#1534, milestone #160 Opt 2b). Both the // set of canonical mood buckets. Both the
// taste-profile builder and the recommendation scorer classify a track's tags // taste-profile builder and the recommendation scorer classify a track's tags
// through Of() so a track's mood is derived the same way on both sides. // through Of() so a track's mood is derived the same way on both sides.
// //
+3 -2
View File
@@ -20,7 +20,7 @@ const DefaultBaseURL = "https://musicbrainz.org/ws/2"
// descriptive one. // descriptive one.
const UserAgent = "Minstrel/1.0 ( https://git.fabledsword.com/bvandeusen/minstrel )" const UserAgent = "Minstrel/1.0 ( https://git.fabledsword.com/bvandeusen/minstrel )"
// DefaultTimeout bounds one request (rule 156). A browse page with track // DefaultTimeout bounds one request. A browse page with track
// lists is the slowest thing asked for. // lists is the slowest thing asked for.
const DefaultTimeout = 20 * time.Second const DefaultTimeout = 20 * time.Second
@@ -33,7 +33,8 @@ const maxPages = 25
var ( var (
// ErrUnreachable: MusicBrainz could not be asked, or kept answering with // ErrUnreachable: MusicBrainz could not be asked, or kept answering with
// its throttle or a server error. Callers degrade visibly (rule 164). // its throttle or a server error. Callers degrade visibly: they say
// MusicBrainz is unreachable rather than show an empty answer.
ErrUnreachable = errors.New("musicbrainz: unreachable") ErrUnreachable = errors.New("musicbrainz: unreachable")
// ErrNotFound: MusicBrainz has nothing under that id. // ErrNotFound: MusicBrainz has nothing under that id.
ErrNotFound = errors.New("musicbrainz: not found") ErrNotFound = errors.New("musicbrainz: not found")
+1 -1
View File
@@ -1,5 +1,5 @@
// Package musicbrainz is Minstrel's client for the MusicBrainz web service: // Package musicbrainz is Minstrel's client for the MusicBrainz web service:
// searches and browses for the release catalogue (M502 #5519), and the one // searches and browses for the release catalogue, and the one
// pacer every MusicBrainz request in the process waits on. // pacer every MusicBrainz request in the process waits on.
package musicbrainz package musicbrainz
+5 -5
View File
@@ -1,6 +1,6 @@
// Package netsettings holds the DB-backed network settings the request path // Package netsettings holds the DB-backed network settings the request path
// needs. Today that's the trusted reverse-proxy depth used to pull a real // needs: the trusted reverse-proxy depth used to pull a real client address
// client address out of X-Forwarded-For (#2453). // out of X-Forwarded-For, and the public address links are built from.
// //
// Values are cached under an RWMutex and refreshed on write. That isn't an // Values are cached under an RWMutex and refreshed on write. That isn't an
// optimisation: auth.ClientIP runs in the RequireUser middleware for every // optimisation: auth.ClientIP runs in the RequireUser middleware for every
@@ -53,7 +53,7 @@ type Service struct {
// It ALWAYS returns a usable Service, even alongside a non-nil error. The // It ALWAYS returns a usable Service, even alongside a non-nil error. The
// value it holds sits on the authenticated request path, so a boot-time // value it holds sits on the authenticated request path, so a boot-time
// database hiccup must degrade to the default rather than take every request // database hiccup must degrade to the default rather than take every request
// down with it (rule #131). The error is returned so the caller can log that // down with it. The error is returned so the caller can log that
// the cache holds a default rather than stored state. // the cache holds a default rather than stored state.
func New(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*Service, error) { func New(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*Service, error) {
s := &Service{pool: pool, logger: logger, hops: DefaultTrustedProxyHops} s := &Service{pool: pool, logger: logger, hops: DefaultTrustedProxyHops}
@@ -84,7 +84,7 @@ func (s *Service) Hops() int {
} }
// SetHops persists a new depth and refreshes the cache, so an admin change // SetHops persists a new depth and refreshes the cache, so an admin change
// takes effect on the next request with no restart (rule #25). // takes effect on the next request with no restart.
func (s *Service) SetHops(ctx context.Context, hops int) error { func (s *Service) SetHops(ctx context.Context, hops int) error {
// Range first, availability second. The argument is wrong regardless of // Range first, availability second. The argument is wrong regardless of
// whether the database is reachable, and the distinction is user-visible: // whether the database is reachable, and the distinction is user-visible:
@@ -115,7 +115,7 @@ func (s *Service) SetHops(ctx context.Context, hops int) error {
return nil return nil
} }
// PublicURL returns the operator-set address users reach Minstrel at, with no // PublicURL returns the admin-set address users reach Minstrel at, with no
// trailing slash, or "" when it hasn't been set. Links that leave the app (a // trailing slash, or "" when it hasn't been set. Links that leave the app (a
// password-reset email) are built from this and never from the request's // password-reset email) are built from this and never from the request's
// Host header, which the requester controls. Nil-safe like Hops. // Host header, which the requester controls. Nil-safe like Hops.
+2 -4
View File
@@ -15,10 +15,8 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer" "git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
) )
// The email digest (#5346). Nothing is emailed per event. Operator, // The email digest. Nothing is emailed per event: a steady stream of new
// 2026-10-08: "music and stuff coming in should land as a summary email and // music or approvals would otherwise be a stream of emails.
// not everytime same for a approvals the emails should be grouped by a
// reasonable amount of time like an hour or so."
// //
// - EmailSummary kinds (new music) go out at most once a day, at the summary // - EmailSummary kinds (new music) go out at most once a day, at the summary
// hour in the user's own timezone, listing everything since the last one. // hour in the user's own timezone, listing everything since the last one.
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
) )
// EmailSettings are the digest's two knobs, in admin Settings (rule 25). // EmailSettings are the digest's two knobs, edited in admin Settings.
type EmailSettings struct { type EmailSettings struct {
// SummaryHour is the local hour (0-23, in each user's own timezone) the // SummaryHour is the local hour (0-23, in each user's own timezone) the
// daily new-music summary goes out. // daily new-music summary goes out.
+11 -13
View File
@@ -1,17 +1,16 @@
// Package notifications is the one writer of the per-user notifications inbox // Package notifications is the one writer of the per-user notifications
// (M489, #726). Producers across the server call Notifier.Notify; nothing else // inbox. Producers across the server call Notifier.Notify; nothing else
// inserts into user_notifications. // inserts into user_notifications.
// //
// The event bus alone was fire-and-forget: a client that was not connected // The event bus is fire-and-forget: a client that is not connected when a
// when a request completed, or when tracks went missing, never heard of it. A // request completes, or when tracks go missing, never hears of it. A row here
// row here is the durable record. The bus only nudges open clients to come // is the durable record. The bus only nudges open clients to come and read
// and read it. // it.
package notifications package notifications
// Kind names one sort of notification. The set is CHECK-gated in migration // Kind names one sort of notification. The set is CHECK-gated in the schema:
// 0073, swapped since by 0075 (rule 36): a new kind adds its value in a // a new kind adds its value (DROP + ADD of the CHECK) in a migration of the
// migration of the same change, and TestEveryKindPassesTheSchemaChecks // same change, and TestEveryKindPassesTheSchemaChecks fails until it does.
// fails until it does.
type Kind string type Kind string
const ( const (
@@ -23,9 +22,8 @@ const (
KindScanFailed Kind = "scan_failed" KindScanFailed Kind = "scan_failed"
KindTracksMissing Kind = "tracks_missing" KindTracksMissing Kind = "tracks_missing"
KindDuplicatesFound Kind = "duplicates_found" KindDuplicatesFound Kind = "duplicates_found"
// KindDuplicatesResolved: the duplicate resolver (M498) merged copies on // KindDuplicatesResolved: the duplicate resolver merged copies on its
// its own. The operator asked for that to // own. This is how an automatic merge stays visible.
// happen automatically; this is how it stays visible.
KindDuplicatesResolved Kind = "duplicates_resolved" KindDuplicatesResolved Kind = "duplicates_resolved"
KindPlaybackErrors Kind = "playback_errors" KindPlaybackErrors Kind = "playback_errors"
) )
+1 -1
View File
@@ -17,7 +17,7 @@ import (
// row lands for them. It is a nudge with no content: the client fetches the // row lands for them. It is a nudge with no content: the client fetches the
// inbox. A frame that carried the notification could not be told apart from // inbox. A frame that carried the notification could not be told apart from
// a repeat after a reconnect, and a frame nobody received is then one nobody // a repeat after a reconnect, and a frame nobody received is then one nobody
// needed, because the client pulls on reconnect anyway (Roundtable #2535). // needed, because the client pulls on reconnect anyway.
const EventCreated = "notification.created" const EventCreated = "notification.created"
// Recipients names who an event is for. // Recipients names who an event is for.
+6 -6
View File
@@ -146,9 +146,9 @@ func TestNotify_CoalescedCountAddsUpWhileUnread_AndStartsAfreshOnceRead(t *testi
require.True(t, rows[1].ReadAt.Valid) require.True(t, rows[1].ReadAt.Valid)
} }
// M542: an update says more, newest names first, without arriving again. A // An update says more, newest names first, without arriving again: a moved
// moved created_at was announced again on the phone, and a cleared emailed_at // created_at would announce it again on the phone, and a cleared emailed_at
// was emailed again. // would email it again.
func TestNotify_ACoalescedUpdateMergesNamesAndKeepsItsArrival(t *testing.T) { func TestNotify_ACoalescedUpdateMergesNamesAndKeepsItsArrival(t *testing.T) {
pool := testPool(t) pool := testPool(t)
q := dbq.New(pool) q := dbq.New(pool)
@@ -238,9 +238,9 @@ func TestMarkRead_ScopedToTheOwner_AndIdempotent(t *testing.T) {
require.Zero(t, unread) require.Zero(t, unread)
} }
// Every kind the code knows must pass both CHECKs in the real schema. This is // Every kind the code knows must pass both CHECKs in the real schema: a kind
// rule 36's guard: a kind added in Go without the migration fails here, not in // added in Go without its migration fails here, not in production at INSERT
// production at INSERT time. // time.
func TestEveryKindPassesTheSchemaChecks(t *testing.T) { func TestEveryKindPassesTheSchemaChecks(t *testing.T) {
pool := testPool(t) pool := testPool(t)
q := dbq.New(pool) q := dbq.New(pool)
+1 -1
View File
@@ -42,7 +42,7 @@ type Payload struct {
// read. // read.
Count int64 `json:"count,omitempty"` Count int64 `json:"count,omitempty"`
// Items name what Count counts, one each, newest first: "Weezer – Buddy // Items name what Count counts, one each, newest first: "Weezer – Buddy
// Holly". A coalesced update puts its own in front and keeps ten (M542), // Holly". A coalesced update puts its own in front and keeps ten,
// so the body can say which, not only how many. // so the body can say which, not only how many.
Items []string `json:"items,omitempty"` Items []string `json:"items,omitempty"`
// Detail is free text: scan_failed's error message. Rows from before // Detail is free text: scan_failed's error message. Rows from before
+1 -1
View File
@@ -45,7 +45,7 @@ func TestRender_CoalescedCountsReadNaturally(t *testing.T) {
require.Equal(t, "Some tracks went missing", Render(KindTracksMissing, []byte(`{}`)).Title) require.Equal(t, "Some tracks went missing", Render(KindTracksMissing, []byte(`{}`)).Title)
} }
// M542: a notice says which, not only how many. // A notice says which, not only how many.
func TestRender_NamesWhatChanged(t *testing.T) { func TestRender_NamesWhatChanged(t *testing.T) {
require.Equal(t, "No longer on disk: Weezer – Buddy Holly.", require.Equal(t, "No longer on disk: Weezer – Buddy Holly.",
render(KindTracksMissing, Payload{Count: 1, Items: []string{"Weezer – Buddy Holly"}}).Body) render(KindTracksMissing, Payload{Count: 1, Items: []string{"Weezer – Buddy Holly"}}).Body)
+21 -34
View File
@@ -33,7 +33,7 @@ type Writer struct {
} }
// NewWriter constructs a writer. timeout is the play-session window; the two // NewWriter constructs a writer. timeout is the play-session window; the two
// remaining args are the skip rule thresholds from spec §6. // remaining args are the skip rule's thresholds.
func NewWriter( func NewWriter(
pool *pgxpool.Pool, pool *pgxpool.Pool,
logger *slog.Logger, logger *slog.Logger,
@@ -78,23 +78,12 @@ func strPtrOrNil(s string) *string {
} }
// systemPlaylistSources are the play_events.source values that count // systemPlaylistSources are the play_events.source values that count
// against a system playlist's rotation (Fable #415). Mirrors the // against a system playlist's rotation. Every variant in the
// `playlists_kind_variant_consistent` CHECK in migration // `playlists_kind_variant_consistent` CHECK that ships as a refreshable
// 0028_discovery_mix_variants.up.sql — every variant in that CHECK // system mix must be here: a variant missing from this map never advances
// list that ships as a refreshable system mix needs to be here so // its per-user rotation, so "unplayed first" goes stale and the same tracks
// the rotation reporter sees Android + web plays from that surface. // keep surfacing. A new variant updates this map and that CHECK (in its
// // migration) in the same change.
// Drift #563: this map drifted behind the migrations. It had only
// for_you + discover but migrations 0021 + 0028 added 6 more
// variants. Plays from Rediscover / Deep Cuts / Songs Like X /
// New for You / On This Day / First Listens didn't advance the
// per-user rotation, so "unplayed first" ordering staled on those
// mixes — same tracks kept surfacing.
//
// Future variant additions should update this map AND the CHECK in
// the matching migration in the same change; the
// `db_check_constraint_for_new_variants` standing rule covers the
// CHECK half.
var systemPlaylistSources = map[string]bool{ var systemPlaylistSources = map[string]bool{
"for_you": true, "for_you": true,
"discover": true, "discover": true,
@@ -107,10 +96,10 @@ var systemPlaylistSources = map[string]bool{
} }
// lookupSystemPickKind resolves a system-playlist play's pick_kind // lookupSystemPickKind resolves a system-playlist play's pick_kind
// (taste/fresh, Discover's bucket, or a rule-#131 tier) from the user's // (taste/fresh, Discover's bucket, or an eligibility tier) from the user's
// live snapshot of that variant at ingestion time. Attribution must be // live snapshot of that variant at ingestion time. Attribution must be
// frozen now — snapshots rebuild daily, so it can't be reconstructed at // frozen now — snapshots rebuild daily, so it can't be reconstructed at
// metrics-read time (#1249, generalized in #1270). Source strings // metrics-read time. Source strings
// double as system_variant values (see systemPlaylistSources). Returns // double as system_variant values (see systemPlaylistSources). Returns
// nil (unattributed) when the track isn't in the current snapshot — // nil (unattributed) when the track isn't in the current snapshot —
// plays predating the feature, or an offline replay arriving after the // plays predating the feature, or an offline replay arriving after the
@@ -140,8 +129,8 @@ func lookupSystemPickKind(
// tag identifying which surface the play came from. When source is a // tag identifying which surface the play came from. When source is a
// known system-playlist kind the track is appended to that user's // known system-playlist kind the track is appended to that user's
// rotation set so shuffle-on-play can prefer the unplayed tail on a // rotation set so shuffle-on-play can prefer the unplayed tail on a
// re-play within the day. Empty source → behaves exactly like the // re-play within the day. An empty source behaves exactly like
// pre-#415 path (NULL column, no rotation write). // RecordPlayStarted (NULL column, no rotation write).
func (w *Writer) RecordPlayStartedWithSource( func (w *Writer) RecordPlayStartedWithSource(
ctx context.Context, ctx context.Context,
userID, trackID pgtype.UUID, userID, trackID pgtype.UUID,
@@ -209,7 +198,7 @@ func (w *Writer) RecordPlayStartedWithSource(
} }
// RecordPlayEnded closes a play_event with duration + computed completion // RecordPlayEnded closes a play_event with duration + computed completion
// ratio. Applies the spec §6 skip rule (AND of completion < threshold AND // ratio. Applies the skip rule (AND of completion < threshold AND
// duration_played < threshold) — both conditions must hold for the row to // duration_played < threshold) — both conditions must hold for the row to
// be marked was_skipped=true. If skipped, also writes a skip_events row. // be marked was_skipped=true. If skipped, also writes a skip_events row.
func (w *Writer) RecordPlayEnded( func (w *Writer) RecordPlayEnded(
@@ -232,7 +221,7 @@ func (w *Writer) RecordPlayEnded(
// row is already closed, re-applying the close is harmless (the // row is already closed, re-applying the close is harmless (the
// UPDATE is last-write-wins) but a second skip_events insert must // UPDATE is last-write-wins) but a second skip_events insert must
// NOT fire — a lost-response retry would otherwise double the // NOT fire — a lost-response retry would otherwise double the
// skip signal. See the 2026-06-11 contract audit. // skip signal.
alreadyEnded := ev.EndedAt.Valid alreadyEnded := ev.EndedAt.Valid
ratio := 0.0 ratio := 0.0
if track.DurationMs > 0 { if track.DurationMs > 0 {
@@ -382,17 +371,15 @@ func (w *Writer) RecordSyntheticCompletedPlay(
} }
// RecordOfflinePlay writes a complete start+end play in one call from // RecordOfflinePlay writes a complete start+end play in one call from
// a caller-supplied timestamp + duration. This is the replay path for // a caller-supplied timestamp + duration. This is the replay path for a
// the Flutter offline mutation queue (#426 part B): a play that // client's offline queue: a play that happened with no or flaky
// happened with no/flaky connectivity is captured locally as a // connectivity is captured locally as a completed unit and replayed here
// completed unit and replayed here once back online, preserving the // once back online, preserving the original `at`.
// original `at`.
// //
// Generalizes RecordSyntheticCompletedPlay: instead of assuming full // Generalizes RecordSyntheticCompletedPlay: instead of assuming full
// completion it applies the spec §6 skip rule from the real // completion it applies the skip rule from the real durationPlayed vs
// durationPlayed vs track length (same AND-of-thresholds as // track length (same AND-of-thresholds as RecordPlayEnded), and threads
// RecordPlayEnded), and threads `source` so #415 rotation advances // `source` so system-playlist rotation advances just like the live path.
// for system-playlist plays just like the live path.
// //
// durationPlayedMs is clamped to [0, track duration] to absorb // durationPlayedMs is clamped to [0, track duration] to absorb
// client clock / position drift. // client clock / position drift.
@@ -414,7 +401,7 @@ func (w *Writer) RecordOfflinePlay(
// after commit makes the client re-fire the same play. The replayed // after commit makes the client re-fire the same play. The replayed
// payload carries the original `at`, so an exact (user, track, // payload carries the original `at`, so an exact (user, track,
// started_at) match means we already recorded it — no-op instead of a // started_at) match means we already recorded it — no-op instead of a
// duplicate history row. See the 2026-06-11 contract audit. // duplicate history row.
var dupID pgtype.UUID var dupID pgtype.UUID
err = tx.QueryRow(ctx, err = tx.QueryRow(ctx,
`SELECT id FROM play_events `SELECT id FROM play_events
+4 -4
View File
@@ -220,8 +220,8 @@ func TestRecordPlayEnded_DurationOver50Percent_NotSkip(t *testing.T) {
} }
// A lost-response retry of an offline play (same user/track/started_at) // A lost-response retry of an offline play (same user/track/started_at)
// must not create a second history row. Guards the at-least-once replay // must not create a second history row: the offline replay is
// path added in the 2026-06-11 audit. // at-least-once.
func TestRecordOfflinePlay_DedupsByUserTrackStartedAt(t *testing.T) { func TestRecordOfflinePlay_DedupsByUserTrackStartedAt(t *testing.T) {
f := newFixture(t, 200_000) f := newFixture(t, 200_000)
ctx := context.Background() ctx := context.Background()
@@ -491,7 +491,7 @@ func TestRecordPlayStarted_NoRotationWithoutSource(t *testing.T) {
// seedSystemSnapshot creates a system playlist of the given variant // seedSystemSnapshot creates a system playlist of the given variant
// containing the fixture track with the given pick_kind, mimicking // containing the fixture track with the given pick_kind, mimicking
// what the builder persists (#1249, generalized #1270). // what the builder persists.
func seedSystemSnapshot(t *testing.T, f fixture, variant, pickKind string) { func seedSystemSnapshot(t *testing.T, f fixture, variant, pickKind string) {
t.Helper() t.Helper()
pl, err := f.q.CreateSystemPlaylist(context.Background(), dbq.CreateSystemPlaylistParams{ pl, err := f.q.CreateSystemPlaylist(context.Background(), dbq.CreateSystemPlaylistParams{
@@ -567,7 +567,7 @@ func TestRecordPlayStartedWithSource_CrossVariant_PickKindNull(t *testing.T) {
} }
func TestRecordPlayStartedWithSource_StampsDiscoverBucket(t *testing.T) { func TestRecordPlayStartedWithSource_StampsDiscoverBucket(t *testing.T) {
// Discover stamps its candidate bucket (#1270); a discover play whose // Discover stamps its candidate bucket; a discover play whose
// track is in the live snapshot freezes that bucket onto the play. // track is in the live snapshot freezes that bucket onto the play.
f := newFixture(t, 200_000) f := newFixture(t, 200_000)
seedSystemSnapshot(t, f, "discover", "dormant") seedSystemSnapshot(t, f, "discover", "dormant")
+1 -2
View File
@@ -1,5 +1,4 @@
// Package playsessions implements the listening-session lifecycle from // Package playsessions implements the listening-session lifecycle: a session is a contiguous sequence of plays by a user with no
// spec §6: a session is a contiguous sequence of plays by a user with no
// gap longer than the configured timeout (default 30 minutes) between // gap longer than the configured timeout (default 30 minutes) between
// events. // events.
package playsessions package playsessions
+2 -3
View File
@@ -1,9 +1,8 @@
// Package prowlarr searches through Prowlarr's native API (M502 #5518). // Package prowlarr searches through Prowlarr's native API.
// Ported from Fabled Librarian's internal/prowlarr.
// //
// Native rather than Torznab because Prowlarr has no aggregate Torznab feed: // Native rather than Torznab because Prowlarr has no aggregate Torznab feed:
// `/api/v1/indexer/all/newznab` 404s exactly like an indexer id that does not // `/api/v1/indexer/all/newznab` 404s exactly like an indexer id that does not
// exist (measured by Fabled Librarian against a real Prowlarr, its #4304). // exist.
// `/api/v1/search` is the one place Prowlarr asks every indexer at once. // `/api/v1/search` is the one place Prowlarr asks every indexer at once.
package prowlarr package prowlarr
+1 -1
View File
@@ -234,7 +234,7 @@ func (s *Service) snapshot(ctx context.Context, q *dbq.Queries, track dbq.Track)
// //
// If the file cannot be removed, nothing is deleted and the per-user rows stay // If the file cannot be removed, nothing is deleted and the per-user rows stay
// so the admin can retry: the error wraps a *library.FileRemoveError, which the // so the admin can retry: the error wraps a *library.FileRemoveError, which the
// handler turns into an answer naming the cause (#3918). No partial state. // handler turns into an answer naming the cause. No partial state.
func (s *Service) DeleteFile(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.QuarantineAction, error) { func (s *Service) DeleteFile(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.QuarantineAction, error) {
q := dbq.New(s.pool) q := dbq.New(s.pool)
track, err := q.GetTrackByID(ctx, trackID) track, err := q.GetTrackByID(ctx, trackID)
+4 -5
View File
@@ -1,12 +1,11 @@
// Package reacquisition wants an album back when its files go missing, // Package reacquisition wants an album back when its files go missing,
// without anyone pressing anything (milestone #290; a want since M502 // without anyone pressing anything.
// #5520).
// //
// The unit of work is the ALBUM, not the track. Music is acquired as // The unit of work is the ALBUM, not the track. Music is acquired as
// releases; there is no meaningful "fetch me one track", and a recording // releases; there is no meaningful "fetch me one track", and a recording
// MBID is something plenty of files lack. Grouping also does most of the // MBID is something plenty of files lack. Grouping also does most of the
// safety work: the loss that produced #2523 — three reorganised albums, ~40 // safety work: a reorganised album's forty missing files become one want,
// missing files — becomes three wants rather than forty. // not forty.
package reacquisition package reacquisition
import ( import (
@@ -21,7 +20,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
) )
// Settings is the operator-tunable policy, mirroring the columns and CHECK // Settings is the admin-tunable policy, mirroring the columns and CHECK
// ranges in migration 0056. // ranges in migration 0056.
type Settings struct { type Settings struct {
Enabled bool Enabled bool
+4 -4
View File
@@ -15,8 +15,8 @@ import (
) )
// Sweeper periodically wants back albums whose files have been missing past // Sweeper periodically wants back albums whose files have been missing past
// the grace window (milestone #290, M502 #5520). Searching for them is the // the grace window. Searching for them is the search schedule's, which
// search schedule's (#5521), which retries per want. // retries per want.
// //
// Deliberately a periodic worker rather than a hook inside the scanner's // Deliberately a periodic worker rather than a hook inside the scanner's
// reconcile pass: reconcile runs inside a scan and re-runs often, and a // reconcile pass: reconcile runs inside a scan and re-runs often, and a
@@ -28,7 +28,7 @@ type Sweeper struct {
logger *slog.Logger logger *slog.Logger
tick time.Duration tick time.Duration
// releaseGroup names the MusicBrainz release group of a release id, for an // releaseGroup names the MusicBrainz release group of a release id, for an
// album whose tags never carried one (#5241). Wants are kept by release // album whose tags never carried one. Wants are kept by release
// group. A field so tests can stand in for MusicBrainz. // group. A field so tests can stand in for MusicBrainz.
releaseGroup func(ctx context.Context, releaseMBID string) (string, error) releaseGroup func(ctx context.Context, releaseMBID string) (string, error)
} }
@@ -79,7 +79,7 @@ func (s *Sweeper) SweepOnce(ctx context.Context) error {
q := dbq.New(s.pool) q := dbq.New(s.pool)
var res PassResult var res PassResult
// Counted, not acted on: an album MusicBrainz cannot name is not a // Counted, not acted on: an album MusicBrainz cannot name is not a
// failure to retry, it is a permanent gap the operator should see. // failure to retry, it is a permanent gap an admin should see.
if n, cerr := q.CountAlbumsMissingWithoutMbid(ctx); cerr == nil { if n, cerr := q.CountAlbumsMissingWithoutMbid(ctx); cerr == nil {
res.Unnameable = n res.Unnameable = n
} }
+5 -6
View File
@@ -83,9 +83,9 @@ func heldWant(t *testing.T, pool *pgxpool.Pool, group string, album pgtype.UUID,
} }
} }
// Only an album on the wanted list is fetched back (#5567): one taken off // Only an album on the wanted list is fetched back: one taken off it, or
// it, or dropped into the library by hand, is not. An album on the list is // dropped into the library by hand, is not. An album on the list is wanted
// wanted by its release group, never its release id (#5241): a tag-supplied // by its release group, never its release id: a tag-supplied
// group is used as is; otherwise MusicBrainz names it and the answer is // group is used as is; otherwise MusicBrainz names it and the answer is
// cached onto the album; an album MusicBrainz cannot name is skipped and // cached onto the album; an album MusicBrainz cannot name is skipped and
// tried again next pass. // tried again next pass.
@@ -103,8 +103,7 @@ func TestSweep_WantsBackOnlyAlbumsOnTheList_Integration(t *testing.T) {
resolved := lostAlbum(t, pool, "Resolved", "release-resolved", "") resolved := lostAlbum(t, pool, "Resolved", "release-resolved", "")
unknown := lostAlbum(t, pool, "Unknown", "release-unknown", "") unknown := lostAlbum(t, pool, "Unknown", "release-unknown", "")
_ = lostAlbum(t, pool, "Off The List", "release-off", "group-off") _ = lostAlbum(t, pool, "Off The List", "release-off", "group-off")
// Matched to a group with no release id: 238 such albums on the operator's // Matched to a group with no release id: still wanted back.
// library were never wanted back (#6167).
groupOnly := lostAlbum(t, pool, "Group Only", "", "group-only") groupOnly := lostAlbum(t, pool, "Group Only", "", "group-only")
heldWant(t, pool, "group-tagged", tagged, "3 days") heldWant(t, pool, "group-tagged", tagged, "3 days")
heldWant(t, pool, "group-only", groupOnly, "3 days") heldWant(t, pool, "group-only", groupOnly, "3 days")
@@ -165,7 +164,7 @@ func TestSweep_WantsBackOnlyAlbumsOnTheList_Integration(t *testing.T) {
} }
// A want held after the files went missing means the library has a full copy // A want held after the files went missing means the library has a full copy
// despite the missing rows (#5245): the album is not wanted again. One held // despite the missing rows: the album is not wanted again. One held
// before they went missing is reopened. // before they went missing is reopened.
func TestSweep_HeldWantsAreReopenedOnlyForANewLoss_Integration(t *testing.T) { func TestSweep_HeldWantsAreReopenedOnlyForANewLoss_Integration(t *testing.T) {
pool := newSweepPool(t) pool := newSweepPool(t)
+1 -1
View File
@@ -171,7 +171,7 @@ const (
tagOverlapWeightMax = 10.0 tagOverlapWeightMax = 10.0
// Snooze duration: at least a day (anything less isn't a snooze, it's a // Snooze duration: at least a day (anything less isn't a snooze, it's a
// flicker), at most a year — past that it's a permanent dismissal wearing a // flicker), at most a year — past that it's a permanent dismissal wearing a
// snooze's clothes, which is exactly the shape rule #101 rules out. // snooze's clothes, and suggestions have no permanent "never show" control.
snoozeDaysMin = 1.0 snoozeDaysMin = 1.0
snoozeDaysMax = 365.0 snoozeDaysMax = 365.0
) )
+36 -44
View File
@@ -1,16 +1,15 @@
// Package recsettings is the DB-backed home of the recommendation // Package recsettings is the DB-backed home of the recommendation
// tuning knobs (#1250): the two scoring-weight profiles (radio / // tuning knobs: the scoring-weight profiles (radio, daily_mix,
// daily_mix) and the taste-profile build settings (engagement // songs_like), Discover's settings, and the taste-profile build
// half-life + completion curve). It follows the coverart // settings (engagement half-life + completion curve). It follows the coverart
// SettingsService pattern — boot reconcile seeds shipped defaults for // SettingsService pattern — boot reconcile seeds shipped defaults for
// missing rows, values are cached under a RWMutex, and every change // missing rows, values are cached under a RWMutex, and every change
// takes effect live (the daily_mix profile + taste config are pushed // takes effect live (the daily_mix profile + taste config are pushed
// into package playlists; radio reads the cache per request). // into package playlists; radio reads the cache per request).
// //
// Framing (decision #1247): this is the defaults-discovery lab. The // This is a lab for finding good defaults: an admin turns knobs to find
// operator turns knobs to FIND good values; found-good values get // good values, and those get baked back into the Shipped* functions below
// baked back into the Shipped* functions below as new shipped // as new shipped defaults. A typical install never needs to touch it.
// defaults. End users and other operators should never need the card.
package recsettings package recsettings
import ( import (
@@ -35,24 +34,23 @@ const (
ScopeRadio = "radio" ScopeRadio = "radio"
ScopeDailyMix = "daily_mix" ScopeDailyMix = "daily_mix"
ScopeTaste = "taste" ScopeTaste = "taste"
// ScopeDiscover is the Discover request surface (#2377). Its own scope // ScopeDiscover is the Discover request surface. Its own scope rather
// rather than columns on taste: SnoozeDays lives here, and a snooze must // than columns on taste: SnoozeDays lives here, and a snooze must never
// never be read as taste signal (#2374) — filing it under taste would put // be read as taste signal — filing it under taste would put it one
// it one careless join from the leak that design forbids. // careless join from that leak.
ScopeDiscover = "discover" ScopeDiscover = "discover"
// ScopeSongsLike is the "Songs like {X}" surface (#3881). It shared // ScopeSongsLike is the "Songs like {X}" surface. It is separate from
// daily_mix with For-You until 2026-09-10, and that sharing WAS the bug: // daily_mix because the two want opposite things: For-You answers "what
// the two surfaces want opposite things. For-You answers "what will they // will they enjoy today" and is supposed to roam; Songs-like answers
// enjoy today" and is supposed to roam; Songs-like answers "what sounds // "what sounds like THIS" and is the tightest surface in the product. One
// like THIS" and is the tightest surface in the product. One set of // set of weights cannot serve both.
// weights cannot serve both, and the broad answer was winning.
ScopeSongsLike = "songs_like" ScopeSongsLike = "songs_like"
) )
// TasteTuning is the tunable subset of taste.Config: the engagement // TasteTuning is the tunable subset of taste.Config: the engagement
// half-life, the completion→engagement curve points, the enriched-tag // half-life, the completion→engagement curve points, the enriched-tag
// weight (how much folksonomy tags count vs raw ID3 genre, #1520), and the // weight (how much folksonomy tags count vs raw ID3 genre), and the
// era-facet weight (how strongly a decade-play imprints, #1530). // era-facet weight (how strongly a decade-play imprints).
type TasteTuning struct { type TasteTuning struct {
HalfLifeDays float64 HalfLifeDays float64
EngagementHardSkip float64 EngagementHardSkip float64
@@ -63,12 +61,10 @@ type TasteTuning struct {
MoodScale float64 MoodScale float64
} }
// ShippedRadioWeights are the shipped radio-profile defaults (moved // ShippedRadioWeights are the shipped radio-profile defaults. Radio is
// here from config.RecommendationConfig — YAML is bootstrap-only, // seed-directed (the user picked a direction), so taste is a lighter nudge
// rule: config in UI). Radio is seed-directed (the user picked a // than in the daily mixes. ContextTimeWeight is 1.0 in both profiles; the
// direction), so taste is a lighter nudge than in the daily mixes. // metrics view is what would justify splitting them.
// ContextTimeWeight starts uniform (1.0) across both profiles pending
// trend data (#1531); split them once the metrics view justifies it.
func ShippedRadioWeights() recommendation.ScoringWeights { func ShippedRadioWeights() recommendation.ScoringWeights {
return recommendation.ScoringWeights{ return recommendation.ScoringWeights{
BaseWeight: 1.0, BaseWeight: 1.0,
@@ -99,12 +95,11 @@ func ShippedDailyMixWeights() recommendation.ScoringWeights {
} }
} }
// ShippedSongsLikeWeights are the shipped songs_like-profile defaults // ShippedSongsLikeWeights are the shipped songs_like-profile defaults. The
// (#3881). The whole point is that SIMILARITY DOMINATES; every other // whole point is that SIMILARITY DOMINATES; every other profile balances it
// profile balances it against taste and engagement, and this one must not. // against taste and engagement, and this one must not.
// //
// The failure being corrected, arithmetic from the daily_mix profile that // Under the daily_mix weights, for example:
// this surface used to share:
// //
// unrelated track, liked, not played recently → 1.0 + 2.0 + 1.0 = 4.0 // unrelated track, liked, not played recently → 1.0 + 2.0 + 1.0 = 4.0
// PERFECT similarity match, not liked → 1.0 + 1.5 = 2.5 // PERFECT similarity match, not liked → 1.0 + 1.5 = 2.5
@@ -132,12 +127,10 @@ func ShippedDailyMixWeights() recommendation.ScoringWeights {
// stays 2.0 because a track the user skips is still unwanted no matter how // stays 2.0 because a track the user skips is still unwanted no matter how
// similar it is. // similar it is.
// //
// These are DEFAULTS, not settings (rule 25) — the operator turns them in the // These are DEFAULTS, not settings: an admin turns them in the tuning card
// admin tuning card and good values get baked back here. They are a // and good values get baked back here. They are a defensible starting point
// defensible starting point rather than a measured optimum: the per-arm fill // rather than a measured optimum, and `likes_overlap` contributes a flat 0.3
// rates and the real sim_score distribution are still unknown (#3879), and // that a high SimilarityWeight amplifies.
// `likes_overlap` contributes a flat 0.3 that a high SimilarityWeight
// amplifies. Expect to move these once that lands.
func ShippedSongsLikeWeights() recommendation.ScoringWeights { func ShippedSongsLikeWeights() recommendation.ScoringWeights {
return recommendation.ScoringWeights{ return recommendation.ScoringWeights{
BaseWeight: 1.0, // same for all candidates; differentiates nothing BaseWeight: 1.0, // same for all candidates; differentiates nothing
@@ -169,12 +162,12 @@ func shippedWeightsFor(scope string) (recommendation.ScoringWeights, bool) {
return recommendation.ScoringWeights{}, false return recommendation.ScoringWeights{}, false
} }
// DiscoverTuning is the tunable set for the Discover request surface (#2377). // DiscoverTuning is the tunable set for the Discover request surface.
type DiscoverTuning struct { type DiscoverTuning struct {
// TagOverlapWeight scales the taste-tag term: score × (1 + w × overlap). // TagOverlapWeight scales the taste-tag term: score × (1 + w × overlap).
// 0 disables it and restores pure similarity ranking. // 0 disables it and restores pure similarity ranking.
TagOverlapWeight float64 TagOverlapWeight float64
// SnoozeDays is the default "not right now" duration (#2374). // SnoozeDays is the default "not right now" duration.
SnoozeDays float64 SnoozeDays float64
} }
@@ -183,12 +176,11 @@ type DiscoverTuning struct {
// TagOverlapWeight 1.0 lets a perfect tag match at most double a candidate's // TagOverlapWeight 1.0 lets a perfect tag match at most double a candidate's
// similarity score — enough to reorder the deck meaningfully, not enough for a // similarity score — enough to reorder the deck meaningfully, not enough for a
// popular-tag coincidence to beat a genuinely strong similarity match. It is a // popular-tag coincidence to beat a genuinely strong similarity match. It is a
// starting point for the tuning lab, not a tuned value: the honest way to pick // starting point, not a tuned value: the way to pick it is the metrics trend
// it is the metrics trend view after some real use. // view after some real use.
// //
// SnoozeDays 90 matches the operator's approved shape: long enough that a // SnoozeDays 90: long enough that a parked suggestion stops nagging, short
// parked suggestion stops nagging, short enough that a taste shift brings it // enough that a taste shift brings it back on its own.
// back on its own.
func ShippedDiscoverTuning() DiscoverTuning { func ShippedDiscoverTuning() DiscoverTuning {
return DiscoverTuning{ return DiscoverTuning{
TagOverlapWeight: 1.0, TagOverlapWeight: 1.0,
@@ -337,7 +329,7 @@ func (s *Service) Taste() TasteTuning {
// Discover returns the cached Discover-tuning values. Read per request by the // Discover returns the cached Discover-tuning values. Read per request by the
// suggestions handler, so an admin change takes effect on the next refresh // suggestions handler, so an admin change takes effect on the next refresh
// with no restart (rule #25). // with no restart.
func (s *Service) Discover() DiscoverTuning { func (s *Service) Discover() DiscoverTuning {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
+6 -9
View File
@@ -282,7 +282,7 @@ func TestUpdate_NoOpWritesNoAudit(t *testing.T) {
} }
} }
// --- Discover scope (#2377, milestone #268 slice 6) --- // --- Discover scope ---
func TestNew_SeedsDiscoverDefaults(t *testing.T) { func TestNew_SeedsDiscoverDefaults(t *testing.T) {
pool := newPool(t) pool := newPool(t)
@@ -310,9 +310,8 @@ func TestUpdateDiscover_PersistsAndAudits(t *testing.T) {
t.Errorf("SnoozeDays = %v, want 30", got) t.Errorf("SnoozeDays = %v, want 30", got)
} }
// The audit row must land under the new scope. This is the assertion that // The audit row must land under the new scope: a scope missing from
// would have caught a missing rule-#36 CHECK migration: without expanding // recommendation_tuning_audit's CHECK fails this INSERT at runtime.
// recommendation_tuning_audit's whitelist, this INSERT fails at runtime.
rows := auditRows(t, pool) rows := auditRows(t, pool)
if len(rows) != 1 { if len(rows) != 1 {
t.Fatalf("audit rows = %d, want 1", len(rows)) t.Fatalf("audit rows = %d, want 1", len(rows))
@@ -343,8 +342,7 @@ func TestUpdateDiscover_Validation(t *testing.T) {
{"weight past the typo bound", map[string]float64{"tag_overlap_weight": 100}}, {"weight past the typo bound", map[string]float64{"tag_overlap_weight": 100}},
// A sub-day snooze isn't a snooze, it's a flicker. // A sub-day snooze isn't a snooze, it's a flicker.
{"snooze under a day", map[string]float64{"snooze_days": 0.5}}, {"snooze under a day", map[string]float64{"snooze_days": 0.5}},
// Past a year it's a permanent dismissal wearing a snooze's clothes — // Past a year it's a permanent dismissal wearing a snooze's clothes.
// the shape rule #101 rules out.
{"snooze past a year", map[string]float64{"snooze_days": 400}}, {"snooze past a year", map[string]float64{"snooze_days": 400}},
} }
for _, c := range cases { for _, c := range cases {
@@ -360,9 +358,8 @@ func TestUpdateDiscover_Validation(t *testing.T) {
} }
} }
// Weight 0 must be accepted — it's the operator's off switch for the whole // Weight 0 must be accepted: it's the off switch for the whole tag term, so
// tag term, so a "must be positive" bound would remove their ability to // a "must be positive" bound would leave no way to disable it.
// disable the feature.
func TestUpdateDiscover_ZeroWeightIsAllowed(t *testing.T) { func TestUpdateDiscover_ZeroWeightIsAllowed(t *testing.T) {
pool := newPool(t) pool := newPool(t)
s := newService(t, pool) s := newService(t, pool)
@@ -7,10 +7,9 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
) )
// The bug, reproduced as a ranking: a track that sounds nothing like the seed // A track that sounds nothing like the seed, but which the user liked and has
// but which the user liked and has not played lately used to OUTRANK a perfect // not played lately, must not outrank a perfect similarity match: Songs-like
// similarity match. Operator, 2026-09-10: "I was getting a seeming wide variety // should stay in the seed's neighbourhood.
// of music from each one when I was hoping to stay in a certain neighborhood."
// //
// This is the whole point of the songs_like profile, so it is asserted as // This is the whole point of the songs_like profile, so it is asserted as
// BEHAVIOUR — two candidates, which one wins — rather than by checking the // BEHAVIOUR — two candidates, which one wins — rather than by checking the
@@ -85,7 +84,7 @@ func TestSongsLikeWeights_SimilarityOutrangesEverySeedIndependentTerm(t *testing
} }
// A scope that is seedable but not resettable is the half-wired shape this // A scope that is seedable but not resettable is the half-wired shape this
// guards: the profile appears in the admin card, the operator turns a knob, // guards: the profile appears in the admin card, an admin turns a knob,
// and Reset then 404s on a scope the rest of the service knows about. // and Reset then 404s on a scope the rest of the service knows about.
func TestShippedWeightsFor_CoversEveryWeightProfile(t *testing.T) { func TestShippedWeightsFor_CoversEveryWeightProfile(t *testing.T) {
for _, scope := range []string{ScopeRadio, ScopeDailyMix, ScopeSongsLike} { for _, scope := range []string{ScopeRadio, ScopeDailyMix, ScopeSongsLike} {
+1 -1
View File
@@ -3,7 +3,7 @@ package release
import "slices" import "slices"
// Comparing a file against a MusicBrainz track: shared by the importer's // Comparing a file against a MusicBrainz track: shared by the importer's
// check of a download and the library listing's match of an album (#5569). // check of a download and the library listing's match of an album.
// LengthFits allows five seconds or 3%, whichever is more: remasters and // LengthFits allows five seconds or 3%, whichever is more: remasters and
// rips differ by a second or two, a different recording by far more. A // rips differ by a second or two, a different recording by far more. A
+1 -1
View File
@@ -1,5 +1,5 @@
// Package release reads music release names and judges them against a // Package release reads music release names and judges them against a
// wanted album (M502 #5521): what a name says it is (format, bitrate, bit // wanted album: what a name says it is (format, bitrate, bit
// depth, source, year, edition), whether it is the album wanted, and which // depth, source, year, edition), whether it is the album wanted, and which
// quality tier it sits in. Nothing here touches the network or the database, // quality tier it sits in. Nothing here touches the network or the database,
// so every rule is testable on a string. // so every rule is testable on a string.
+3 -3
View File
@@ -44,7 +44,7 @@ var Tiers = []TierInfo{
} }
// DefaultTiers is what a new install allows: every format from trash lossy // DefaultTiers is what a new install allows: every format from trash lossy
// to lossless (note #5527). Unknown is left out; a // to lossless. Unknown is left out; a
// release that names no format has its .torrent read to find out. // release that names no format has its .torrent read to find out.
var DefaultTiers = []Tier{ var DefaultTiers = []Tier{
TierLosslessHiRes, TierLossless, TierLossyHigh, TierLossyStandard, TierLosslessHiRes, TierLossless, TierLossyHigh, TierLossyStandard,
@@ -151,7 +151,7 @@ func khz(f float64) string {
return strings.TrimSuffix(strconv.FormatFloat(f, 'f', 1, 64), ".0") return strings.TrimSuffix(strconv.FormatFloat(f, 'f', 1, 64), ".0")
} }
// Profile is which tiers the operator takes. // Profile is which tiers an install takes.
type Profile struct { type Profile struct {
Allowed map[Tier]bool Allowed map[Tier]bool
} }
@@ -216,7 +216,7 @@ func FileKbps(bytes, ms int64) int {
// mp3High is the average bitrate from which an MP3 counts as high quality // mp3High is the average bitrate from which an MP3 counts as high quality
// when only its size is known. A V0 encode averages 220 to 260 kbps, so the // when only its size is known. A V0 encode averages 220 to 260 kbps, so the
// line sits below it: an estimate errs toward the copy held, and an album is // line sits below it: an estimate errs toward the copy held, and an album is
// never fetched again on a guess (#5524). // never fetched again on a guess.
const mp3High = 220 const mp3High = 220
// TierOfFile places files of one format on the ladder from their average // TierOfFile places files of one format on the ladder from their average
+1 -1
View File
@@ -16,7 +16,7 @@ func TestParse_ReadsWhatTheNameSays(t *testing.T) {
rate float64 rate float64
tier Tier tier Tier
}{ }{
// The wrong edition grabbed on 2026-10-09 (note #5527). // A bracketed edition with its own year: not the original album.
{"Daft Punk Random Access Memories (Drumless Edition 2023) [Flac 24-88]", FormatFLAC, "", "", 0, 24, 2023, 88.2, TierLosslessHiRes}, {"Daft Punk Random Access Memories (Drumless Edition 2023) [Flac 24-88]", FormatFLAC, "", "", 0, 24, 2023, 88.2, TierLosslessHiRes},
{"Gorillaz - Humanz - (Japanese Edition) - [CD-FLAC] 2017", FormatFLAC, "", SourceCD, 0, 0, 2017, 0, TierLossless}, {"Gorillaz - Humanz - (Japanese Edition) - [CD-FLAC] 2017", FormatFLAC, "", SourceCD, 0, 0, 2017, 0, TierLossless},
{"Radiohead - OK Computer (1997) [MP3 320]", FormatMP3, "", "", 320, 0, 1997, 0, TierLossyHigh}, {"Radiohead - OK Computer (1997) [MP3 320]", FormatMP3, "", "", 320, 0, 1997, 0, TierLossyHigh},
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings" "strings"
) )
// Reading a .torrent file, ported from Fabled Librarian (#4463). A .torrent // Reading a .torrent file. A .torrent
// is the metadata: its info dictionary names every file, and its SHA-1 is the // is the metadata: its info dictionary names every file, and its SHA-1 is the
// infohash. So a release offered as a .torrent link can be judged on what it // infohash. So a release offered as a .torrent link can be judged on what it
// holds before any of it is downloaded. Bencode is four shapes; walking them // holds before any of it is downloaded. Bencode is four shapes; walking them
+1 -1
View File
@@ -5,7 +5,7 @@ import "strings"
// TitleVersion is the version of a recording a release group's title names // TitleVersion is the version of a recording a release group's title names
// in its qualifier: the words in brackets, or after a dash. MusicBrainz // in its qualifier: the words in brackets, or after a dash. MusicBrainz
// does not always type such a group: My Chemical Romance's "Common People // does not always type such a group: My Chemical Romance's "Common People
// (Recorded For BBC Radio 1)" is a plain Single (#5573). Typ is the // (Recorded For BBC Radio 1)" is a plain Single. Typ is the
// MusicBrainz secondary type the version is, or "" for one no type names // MusicBrainz secondary type the version is, or "" for one no type names
// (an acoustic or instrumental version); Word is the word that said so. // (an acoustic or instrumental version); Word is the word that said so.
type TitleVersion struct { type TitleVersion struct {
+1 -1
View File
@@ -4,7 +4,7 @@ import "testing"
func TestVersionInTitle(t *testing.T) { func TestVersionInTitle(t *testing.T) {
for title, want := range map[string]string{ for title, want := range map[string]string{
// My Chemical Romance's groups MusicBrainz types as plain Singles (#5573). // Groups MusicBrainz types as plain Singles, named by their title.
"Common People (Recorded For BBC Radio 1)": "Live", "Common People (Recorded For BBC Radio 1)": "Live",
"Welcome to the Black Parade (Steve Aoki remix)": "Remix", "Welcome to the Black Parade (Steve Aoki remix)": "Remix",
"The Five of Us Are Dying (Rough mix)": "Demo", "The Five of Us Are Dying (Rough mix)": "Demo",
+2 -2
View File
@@ -36,8 +36,8 @@ func NewReconciler(pool *pgxpool.Pool, logger *slog.Logger, bus *eventbus.Bus) *
return &Reconciler{pool: pool, logger: logger, bus: bus, tick: 5 * time.Minute, batch: 50} return &Reconciler{pool: pool, logger: logger, bus: bus, tick: 5 * time.Minute, batch: 50}
} }
// SetNotifier makes completions land in the requester's notifications inbox // SetNotifier makes completions land in the requester's notifications inbox.
// (M489). Without one, completions are only broadcast on the bus. // Without one, completions are only broadcast on the bus.
func (r *Reconciler) SetNotifier(n *notifications.Notifier) { r.notifier = n } func (r *Reconciler) SetNotifier(n *notifications.Notifier) { r.notifier = n }
// publishCompleted tells the requester their request arrived: a // publishCompleted tells the requester their request arrived: a
+2 -2
View File
@@ -1,6 +1,6 @@
// Package requests owns the lifecycle of what users ask to have added. The // Package requests owns the lifecycle of what users ask to have added. The
// synchronous Service handles Create/List/Approve/Reject/Cancel; approving // synchronous Service handles Create/List/Approve/Reject/Cancel; approving
// records the request on the wanted list (M502 #5520): an album or a track // records the request on the wanted list: an album or a track
// wants its album's release group, an artist is followed. The async // wants its album's release group, an artist is followed. The async
// Reconciler (separate file) completes approved requests once the library // Reconciler (separate file) completes approved requests once the library
// holds what they asked for. // holds what they asked for.
@@ -60,7 +60,7 @@ func (s *Service) Create(ctx context.Context, userID pgtype.UUID, p CreateParams
// CreateTracked is Create, also reporting whether a row was inserted. A // CreateTracked is Create, also reporting whether a row was inserted. A
// request that dedupes into one already in flight is not new, so callers // request that dedupes into one already in flight is not new, so callers
// that announce new requests (M489) say nothing for it. // that announce new requests say nothing for it.
func (s *Service) CreateTracked(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.Request, bool, error) { func (s *Service) CreateTracked(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.Request, bool, error) {
if err := validateKindFields(p); err != nil { if err := validateKindFields(p); err != nil {
return dbq.Request{}, false, err return dbq.Request{}, false, err
+2 -3
View File
@@ -7,9 +7,8 @@ package scrobble
// recommended scrobble threshold: ≥240 seconds played OR ≥50% of the // recommended scrobble threshold: ≥240 seconds played OR ≥50% of the
// track length played. // track length played.
// //
// The two thresholds are deliberately separate from M2's skip-detection // The two thresholds are deliberately separate from the skip-detection
// thresholds (which serve a different purpose — internal engine signal // thresholds, which feed the scoring formula's skip penalty instead.
// for the scoring formula's skip penalty).
func Qualifies(durationPlayedMs int, completionRatio float64) bool { func Qualifies(durationPlayedMs int, completionRatio float64) bool {
return durationPlayedMs >= 240_000 || completionRatio >= 0.5 return durationPlayedMs >= 240_000 || completionRatio >= 0.5
} }
+1 -1
View File
@@ -14,7 +14,7 @@ import (
) )
// backoffSchedule maps the failure count (1-indexed) to the delay before // backoffSchedule maps the failure count (1-indexed) to the delay before
// the next attempt. Per spec §M4a: 1m → 5m → 30m → 2h → 6h, then give up. // the next attempt: 1m → 5m → 30m → 2h → 6h, then give up.
var backoffSchedule = []time.Duration{ var backoffSchedule = []time.Duration{
1 * time.Minute, 1 * time.Minute,
5 * time.Minute, 5 * time.Minute,
+2 -2
View File
@@ -120,8 +120,8 @@ func requireJSONForCookieWrites(next http.Handler) http.Handler {
// by the SPA handler, which knows the inline-script hashes. // by the SPA handler, which knows the inline-script hashes.
// //
// HSTS goes out only when the request reached us over HTTPS as the trusted // HSTS goes out only when the request reached us over HTTPS as the trusted
// proxy reports it (rule 94): sending it over plain HTTP is ignored by // proxy reports it: sent over plain HTTP it is ignored by browsers at best,
// browsers at best, and the app never forces HTTPS. // and the app never forces HTTPS.
func securityHeaders(hops func() int) func(http.Handler) http.Handler { func securityHeaders(hops func() int) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+1 -1
View File
@@ -115,7 +115,7 @@ func TestSecurityHeaders(t *testing.T) {
t.Errorf("Permissions-Policy = %q", base.Get("Permissions-Policy")) t.Errorf("Permissions-Policy = %q", base.Get("Permissions-Policy"))
} }
// Rule 94's three cases for HSTS. // HSTS with no trusted proxy, and through one saying https or http.
if got := serve(0, "https").Get("Strict-Transport-Security"); got != "" { if got := serve(0, "https").Get("Strict-Transport-Security"); got != "" {
t.Errorf("hops 0 + forwarded https: HSTS = %q, want none", got) t.Errorf("hops 0 + forwarded https: HSTS = %q, want none", got)
} }
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
// The publish gate (rule 177, M462 #4984), pinned. release.yml holds every // The publish gate, pinned. release.yml holds every
// verifying lane and every publishing job in one graph so that a publish can // verifying lane and every publishing job in one graph so that a publish can
// depend on the lanes; these tests keep that dependency from eroding. // depend on the lanes; these tests keep that dependency from eroding.
// //
+16 -26
View File
@@ -10,17 +10,10 @@ import (
"testing" "testing"
) )
// Guards the version derivation that stamps every build. // Guards the version derivation that stamps every build, in ci/version.sh.
// // Its failure mode is silence: a version nobody can compare looks exactly
// These assertions used to be impossible to run. The derivation lived inline // like being up to date, and nobody reports an update they were never
// in release.yml, which triggers only on `main` and on tags — so a mistake in // offered. Keeping it in a script lets these tests run it on every push.
// it could not surface until a release was already under way, and its failure
// mode is silence: a version nobody can compare looks exactly like being up to
// date, and nobody reports an update they were never offered.
//
// Moving it to ci/version.sh made it executable, so this runs on every push
// that touches the release machinery. That is the whole point of the file; the
// specific assertions below matter less than the fact that they run at all.
// //
// The tests EXECUTE the script rather than asserting on its text, so they // The tests EXECUTE the script rather than asserting on its text, so they
// break when the behaviour changes rather than when the wording does. // break when the behaviour changes rather than when the wording does.
@@ -188,8 +181,7 @@ var jobBoundary = regexp.MustCompile(`\n\n [^ \n]`)
// //
// The naive cut — "up to the next `- name:`" — silently returns an EMPTY body // The naive cut — "up to the next `- name:`" — silently returns an EMPTY body
// for the last step in a job, and every assertion over it then passes // for the last step in a job, and every assertion over it then passes
// vacuously. That is the failure rule 167 names: a check that reads as // vacuously: a check that reads as coverage while asserting on nothing. Cutting at a blank line followed by
// coverage while asserting on nothing. Cutting at a blank line followed by
// job-level indentation handles the last-step case, which is exactly where // job-level indentation handles the last-step case, which is exactly where
// `Build and push` sits. // `Build and push` sits.
func stepBody(t *testing.T, yaml, name string) string { func stepBody(t *testing.T, yaml, name string) string {
@@ -274,7 +266,7 @@ func TestDevChannel_PublishesDevAloneAndNeverLatest(t *testing.T) {
if strings.Contains(arm, ":latest") { if strings.Contains(arm, ":latest") {
t.Errorf("dev arm moves :latest — that ships dev code to every stable operator\n%s", arm) t.Errorf("dev arm moves :latest — that ships dev code to every stable operator\n%s", arm)
} }
// Rule 145: a rolling channel gets no commit-addressable tag. // A rolling channel gets no commit-addressable tag.
if strings.Contains(arm, "GITHUB_SHA") { if strings.Contains(arm, "GITHUB_SHA") {
t.Errorf("dev arm publishes a per-commit tag; a rolling channel should not\n%s", arm) t.Errorf("dev arm publishes a per-commit tag; a rolling channel should not\n%s", arm)
} }
@@ -367,21 +359,19 @@ func TestMainChannel_PublishesLatestAndTheRollbackUnit(t *testing.T) {
if !strings.Contains(arm, "${IMAGE}:${GITHUB_SHA}") { if !strings.Contains(arm, "${IMAGE}:${GITHUB_SHA}") {
t.Errorf("main arm publishes no commit-addressable image; there is no rollback target for production commits\n%s", arm) t.Errorf("main arm publishes no commit-addressable image; there is no rollback target for production commits\n%s", arm)
} }
// Rule 147: :latest tracks main's tip, and a second name for the same // :latest tracks main's tip, and a second name for the same
// image sends readers looking for a distinction that does not exist. // image sends readers looking for a distinction that does not exist.
if strings.Contains(arm, "${IMAGE}:main") { if strings.Contains(arm, "${IMAGE}:main") {
t.Errorf("main arm publishes :main — rule 147 says that tag should not exist\n%s", arm) t.Errorf("main arm publishes :main — rule 147 says that tag should not exist\n%s", arm)
} }
} }
// A release refreshes the CHANNEL and mints nothing else (rules 145 + 146). // A release refreshes the CHANNEL and mints nothing else.
// //
// The specific regression: re-adding :<sha> here. The tag build rebuilds the // The regression to guard: a :<sha> tag here. The tag build rebuilds the SAME
// SAME SOURCE as main's build minutes earlier, differing only in which APK is // SOURCE as main's build minutes earlier, differing only in which APK is baked
// baked in — so a :<sha> minted here would overwrite main's immutable rollback // in — so a :<sha> minted here would overwrite main's immutable rollback
// target with different contents, under the same name. That is the exact thing // target with different contents, under the same name.
// rule 145's immutability clause exists to prevent, and it is the half with
// the incidents behind it.
func TestReleaseBuild_RefreshesTheChannelAndMintsNothingElse(t *testing.T) { func TestReleaseBuild_RefreshesTheChannelAndMintsNothingElse(t *testing.T) {
arm, _, _ := imageTagArms(t, releaseYAML(t)) arm, _, _ := imageTagArms(t, releaseYAML(t))
@@ -393,8 +383,8 @@ func TestReleaseBuild_RefreshesTheChannelAndMintsNothingElse(t *testing.T) {
} }
} }
// No version-numbered image tags anywhere, on any arm (rule 145, and the // No version-numbered image tags anywhere, on any arm: the build's version is
// operator's 2026-09-10 decision to drop them across every project). // self-reported, and the tag map is :latest, :<sha> and :dev.
// //
// Asserted across the whole step rather than per-arm because the mistake this // Asserted across the whole step rather than per-arm because the mistake this
// catches is re-adding one ANYWHERE, and the tag arm is only the likeliest // catches is re-adding one ANYWHERE, and the tag arm is only the likeliest
@@ -441,9 +431,9 @@ func TestVerifyJob_ChecksTheRollbackImageNotAVersionTag(t *testing.T) {
} }
} }
// The server's self-reported version is now the ONLY thing that identifies a // The server's self-reported version is the ONLY thing that identifies a
// build, so a lane that stamps a channel word instead of a version silently // build, so a lane that stamps a channel word instead of a version silently
// removes that ability. It used to stamp the literal "main"/"dev". // removes that ability.
func TestImageBuild_StampsADerivedVersionAndAChannel(t *testing.T) { func TestImageBuild_StampsADerivedVersionAndAChannel(t *testing.T) {
yaml := releaseYAML(t) yaml := releaseYAML(t)
+7 -9
View File
@@ -19,18 +19,16 @@ import (
// hits it every 5s and would otherwise drown real signals (~17k lines/day). // hits it every 5s and would otherwise drown real signals (~17k lines/day).
// //
// Severity is keyed off the response status so 4xx/5xx surface even when // Severity is keyed off the response status so 4xx/5xx surface even when
// the operator's logger level is set above Info. // the logger level is set above Info.
// //
// The `remote` attribute holds the address resolved through the operator's // The `remote` attribute holds the address resolved through the configured
// configured reverse-proxy depth, NOT the raw socket peer (#2453). Behind a // reverse-proxy depth, NOT the raw socket peer. Behind a proxy — the normal
// proxy — the normal deployment for anything public — the socket peer is the // deployment for anything public — the socket peer is the proxy, so every
// proxy, so every line would have carried the same useless address, and the // line would carry the same useless address, and the access log would
// access log would have disagreed with the Active-sessions surface about who // disagree with the Active-sessions list about who connected.
// connected. The attribute key is unchanged so existing log greps keep
// working; only its accuracy improved.
// //
// trustedHops is a func because this middleware is constructed at boot while // trustedHops is a func because this middleware is constructed at boot while
// the value is operator-editable at runtime, and — since Router() registers // the value is editable at runtime, and — since Router() registers
// this before it builds the settings service — because it lets the accessor // this before it builds the settings service — because it lets the accessor
// be wired before the thing it reads exists. auth.ClientIP tolerates a depth // be wired before the thing it reads exists. auth.ClientIP tolerates a depth
// of 0, which is what a nil service reports. // of 0, which is what a nil service reports.
+3 -4
View File
@@ -109,8 +109,7 @@ func TestRequestLog_AttributesPresent(t *testing.T) {
} }
} }
// The point of routing #2453 through the access log: behind a proxy, `remote` // Behind a proxy, the access log's `remote` must be the client rather than the proxy, and must agree with what the
// must be the client rather than the proxy, and must agree with what the
// Active-sessions surface records for the same request. Logs and UI // Active-sessions surface records for the same request. Logs and UI
// disagreeing about who connected is worse than either being wrong alone. // disagreeing about who connected is worse than either being wrong alone.
func TestRequestLog_RemoteHonoursTrustedProxyDepth(t *testing.T) { func TestRequestLog_RemoteHonoursTrustedProxyDepth(t *testing.T) {
@@ -136,8 +135,8 @@ func TestRequestLog_RemoteHonoursTrustedProxyDepth(t *testing.T) {
want: "203.0.113.200", want: "203.0.113.200",
}, },
{ {
// The case that motivated the change: proxy on a PUBLIC address, // A proxy on a PUBLIC address: a "trust XFF only from private
// which the pre-#2453 heuristic logged as the proxy forever. // peers" heuristic would log the proxy forever.
name: "depth 1 through a public-addressed proxy logs the client", name: "depth 1 through a public-addressed proxy logs the client",
hops: func() int { return 1 }, hops: func() int { return 1 },
remoteAddr: "203.0.113.200:40000", remoteAddr: "203.0.113.200:40000",
+16 -18
View File
@@ -65,34 +65,34 @@ type Server struct {
// When nil, Router() constructs a local fallback (test contexts). // When nil, Router() constructs a local fallback (test contexts).
Bus *eventbus.Bus Bus *eventbus.Bus
// PlaylistScheduler fires per-user daily system-playlist builds at // PlaylistScheduler fires per-user daily system-playlist builds at
// 03:00 in each user's stored timezone (#392 Half B). Constructed // 03:00 in each user's stored timezone. Constructed
// in cmd/minstrel/main.go and threaded into the API handlers so // in cmd/minstrel/main.go and threaded into the API handlers so
// PUT /api/me/timezone and POST /api/auth/register can call // PUT /api/me/timezone and POST /api/auth/register can call
// Refresh synchronously. // Refresh synchronously.
PlaylistScheduler *playlists.Scheduler PlaylistScheduler *playlists.Scheduler
// RecSettings is the DB-backed recommendation tuning lab (#1250): // RecSettings is the DB-backed recommendation tuning:
// scoring-weight profiles + taste-build knobs. Constructed in // scoring-weight profiles + taste-build knobs. Constructed in
// cmd/minstrel/main.go (it pushes daily-mix weights into package // cmd/minstrel/main.go (it pushes daily-mix weights into package
// playlists at boot); the API layer reads radio weights per request // playlists at boot); the API layer reads radio weights per request
// and serves the admin tuning endpoints from it. Router() constructs // and serves the admin tuning endpoints from it. Router() constructs
// a fallback when nil (tests). // a fallback when nil (tests).
RecSettings *recsettings.Service RecSettings *recsettings.Service
// FingerprintSettings is the DB-backed fingerprinting policy (M400 #3913). // FingerprintSettings is the DB-backed fingerprinting policy.
// Constructed in cmd/minstrel/main.go and shared with the scanner and the // Constructed in cmd/minstrel/main.go and shared with the scanner and the
// fingerprint workers, so a save from the admin card reaches them without a // fingerprint workers, so a save from the admin card reaches them without a
// restart. Router() constructs a fallback when nil (tests). // restart. Router() constructs a fallback when nil (tests).
FingerprintSettings *library.FingerprintSettingsService FingerprintSettings *library.FingerprintSettingsService
// LoudnessSettings is the DB-backed loudness analysis policy (M464 #4995), // LoudnessSettings is the DB-backed loudness analysis policy,
// shared with the loudness backfill. Nil makes the router load its own. // shared with the loudness backfill. Nil makes the router load its own.
LoudnessSettings *library.LoudnessSettingsService LoudnessSettings *library.LoudnessSettingsService
// AcoustIDLookup is the AcoustID lookup worker (M401), whose settings, // AcoustIDLookup is the AcoustID lookup worker, whose settings,
// status and "look up now" the admin card drives. Nil in test contexts; // status and "look up now" the admin card drives. Nil in test contexts;
// one is built (and never run) so the admin routes still answer. // one is built (and never run) so the admin routes still answer.
AcoustIDLookup *library.AcoustIDLookupWorker AcoustIDLookup *library.AcoustIDLookupWorker
// ReacqSettings is the DB-backed missing-file re-acquisition policy // ReacqSettings is the DB-backed missing-file re-acquisition policy — the
// (milestone #290) — the same instance the sweeper in cmd/minstrel/main.go // same instance the sweeper in cmd/minstrel/main.go reads, so a save from
// reads, so a save from the admin card reaches it without a restart // the admin card reaches it without a restart. Router() constructs a
// (#3936). Router() constructs a fallback when nil (tests). // fallback when nil (tests).
ReacqSettings *reacquisition.SettingsService ReacqSettings *reacquisition.SettingsService
// StreamSecret is the HMAC key used by /api/cast/stream-token to // StreamSecret is the HMAC key used by /api/cast/stream-token to
// mint signed UPnP / Sonos stream URLs and by /api/tracks/{id}/stream // mint signed UPnP / Sonos stream URLs and by /api/tracks/{id}/stream
@@ -154,13 +154,11 @@ func (s *Server) Router() http.Handler {
tracksSvc := tracks.NewService(s.Pool, s.Logger, s.DataDir) tracksSvc := tracks.NewService(s.Pool, s.Logger, s.DataDir)
playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir) playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir)
smtpSender := mailer.NewSMTPSender(s.Pool, s.Logger.With("component", "mailer")) smtpSender := mailer.NewSMTPSender(s.Pool, s.Logger.With("component", "mailer"))
// Live-event bus for SSE subscribers (#392). Constructed per-process; // Live-event bus for SSE subscribers, one per process: producers in
// producers in playevents / requests / scanner publish into // playevents / requests / scanner publish into the same instance.
// the same instance. Background workers (request reconciler, scan // Background workers are built in cmd/minstrel/main.go before Router()
// scheduler) live in cmd/minstrel/main.go where they're constructed // runs and share s.Bus. With no bus set (tests), a fresh local one is
// before Router() runs; they read s.Bus directly so they share this // used.
// process's bus. When Router() runs before main set the bus (tests,
// or future contexts) we fall back to a fresh local instance.
bus := s.Bus bus := s.Bus
if bus == nil { if bus == nil {
bus = eventbus.New() bus = eventbus.New()
@@ -240,8 +238,8 @@ func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
} }
// handleAdminScan runs a scan synchronously and returns the resulting stats. // handleAdminScan runs a scan synchronously and returns the resulting stats.
// Kept synchronous for v1: libraries are small and the operator can tell when // Synchronous, so the caller can tell when it's done; the scan's progress is
// it's done. Async jobs with status polling are a later-milestone concern. // also published as scan-run events.
func (s *Server) handleAdminScan(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminScan(w http.ResponseWriter, r *http.Request) {
stats, err := s.Scanner.Scan(r.Context(), nil) stats, err := s.Scanner.Scan(r.Context(), nil)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
+5 -7
View File
@@ -244,13 +244,11 @@ func TestRouter_AdminSubtreeNotShadowed(t *testing.T) {
} }
} }
// TestRouter_ReacquisitionSettingsSavedThroughTheAPIReachTheSweeper is a // TestRouter_ReacquisitionSettingsSavedThroughTheAPIReachTheSweeper: a
// regression test for #3936. Router() used to construct a // Router() that built a reacquisition.SettingsService of its own would refresh
// reacquisition.SettingsService of its own, so a save from the admin card // THAT instance's cache on a save, while the sweeper in cmd/minstrel/main.go
// refreshed THAT instance's cache while the sweeper in cmd/minstrel/main.go // kept serving what it loaded at boot. The card would show the new policy and
// kept serving what it had loaded at boot. The card showed the new policy, the // the feature run the old one until a restart.
// feature kept running the old one, and only a restart reconciled them — the
// exact thing rule 25 says a setting must not need.
// //
// The assertion is made against the instance main.go hands the sweeper: save // The assertion is made against the instance main.go hands the sweeper: save
// through the router, then read that instance. A second service leaves it stale. // through the router, then read that instance. A second service leaves it stale.
+1 -1
View File
@@ -6,4 +6,4 @@ package server
const MinClientVersion = "0.1.0" const MinClientVersion = "0.1.0"
// The build's version and channel live in internal/buildinfo, shared with // The build's version and channel live in internal/buildinfo, shared with
// the Subsonic envelope and the admin Overview (M504). // the Subsonic envelope and the admin Overview.
+8 -8
View File
@@ -5,14 +5,14 @@
// //
// For recordings it keeps LB's whole answer in listenbrainz_similar_recordings // For recordings it keeps LB's whole answer in listenbrainz_similar_recordings
// and derives the track_similarity edges from it in SQL, against whatever is // and derives the track_similarity edges from it in SQL, against whatever is
// in the library at the time (#5296). Matching once at fetch time missed every // in the library at the time, so a recording that reaches the library later
// recording that reached the library afterwards, through an import or an MBID // (an import, or an MBID the AcoustID lookup fills in) gains its edges without
// the AcoustID lookup filled in. For artists it stores the in-library matches in // asking LB again. For artists it stores the in-library matches in
// artist_similarity and the rest in artist_similarity_unmatched, as before. // artist_similarity and the rest in artist_similarity_unmatched.
// //
// Every answer is recorded as a fetch, an empty one included, and the queue // Every answer is recorded as a fetch, an empty one included, and the queue
// reads those records: freshness used to be read from the edges, so a seed // reads those records rather than the edges: a seed that matched nothing is
// that matched nothing was never fresh and held the head of the queue. // still fresh and does not hold the head of the queue.
package similarity package similarity
import ( import (
@@ -200,8 +200,8 @@ func (w *Worker) upsertArtistSimilar(ctx context.Context, q *dbq.Queries, artist
} }
sort.Slice(results, func(i, j int) bool { return results[i].Score > results[j].Score }) sort.Slice(results, func(i, j int) bool { return results[i].Score > results[j].Score })
// Scores are stored relative to the seed's closest artist, as track edges // Scores are stored relative to the seed's closest artist, as track edges
// are (#6177): LB's raw scores are counts (16 to 10,875 on the operator's // are: LB's raw scores are unbounded counts, and both the candidate pool
// library), and both the candidate pool and the suggestions read 0 to 1. // and the suggestions read 0 to 1.
best := results[0].Score best := results[0].Score
mbids := make([]string, 0, len(results)) mbids := make([]string, 0, len(results))
+11 -11
View File
@@ -209,9 +209,9 @@ func TestTickOnce_MapsLBResponseToLocalLibrary(t *testing.T) {
} }
} }
// LB's scores are counts, not 0 to 1 (16 to 1445 on the operator's library). // LB's scores are counts, not 0 to 1. An edge is stored over the seed's best
// An edge is stored over the seed's best score in the whole answer, in the // score in the whole answer, in the library or not, so the scorer reads it as
// library or not, so the scorer reads it as 0 to 1 (#6177). // 0 to 1.
func TestTickOnce_EdgeIsRelativeToTheSeedsBest(t *testing.T) { func TestTickOnce_EdgeIsRelativeToTheSeedsBest(t *testing.T) {
f := newFixture(t) f := newFixture(t)
mbidA := "11111111-1111-1111-1111-111111111111" mbidA := "11111111-1111-1111-1111-111111111111"
@@ -241,8 +241,8 @@ func TestTickOnce_EdgeIsRelativeToTheSeedsBest(t *testing.T) {
} }
} }
// The 20-per-seed cap is gone with the cache (#5296): LB's answer is at most // LB's answer is at most 50, and every recording of it that is in the library
// 50, and every recording of it that is in the library becomes an edge. // becomes an edge: no per-seed cap.
func TestTickOnce_KeepsEveryInLibraryMatch(t *testing.T) { func TestTickOnce_KeepsEveryInLibraryMatch(t *testing.T) {
f := newFixture(t) f := newFixture(t)
mbidSeed := "11111111-1111-1111-1111-111111111111" mbidSeed := "11111111-1111-1111-1111-111111111111"
@@ -311,7 +311,7 @@ func TestTickOnce_RefreshesStaleRow(t *testing.T) {
_ = f.pool.QueryRow(context.Background(), _ = f.pool.QueryRow(context.Background(),
`SELECT score, fetched_at FROM track_similarity WHERE track_a_id = $1 AND track_b_id = $2 AND source = 'listenbrainz'`, `SELECT score, fetched_at FROM track_similarity WHERE track_a_id = $1 AND track_b_id = $2 AND source = 'listenbrainz'`,
trackA.ID, other.ID).Scan(&score, &fetchedAt) trackA.ID, other.ID).Scan(&score, &fetchedAt)
// The seed's only answer is its best, so the refreshed edge is 1.0 (#6177). // The seed's only answer is its best, so the refreshed edge is 1.0.
if score != 1.0 { if score != 1.0 {
t.Errorf("score = %v, want 1.0 (refreshed, relative to the seed's best)", score) t.Errorf("score = %v, want 1.0 (refreshed, relative to the seed's best)", score)
} }
@@ -452,7 +452,7 @@ func TestUpsertArtistSimilar_PersistsUnmatchedToTable(t *testing.T) {
w := newTestWorker(f, "") w := newTestWorker(f, "")
similars := []listenbrainz.SimilarArtist{ similars := []listenbrainz.SimilarArtist{
// LB's scores are counts; rows are stored over the seed's best (#6177). // LB's scores are counts; rows are stored over the seed's best.
{MBID: inLibMBID, Name: "InLib Artist", Score: 1000}, {MBID: inLibMBID, Name: "InLib Artist", Score: 1000},
{MBID: "out-mbid-1", Name: "Outsider One", Score: 850}, {MBID: "out-mbid-1", Name: "Outsider One", Score: 850},
{MBID: "out-mbid-2", Name: "Outsider Two", Score: 800}, {MBID: "out-mbid-2", Name: "Outsider Two", Score: 800},
@@ -543,9 +543,9 @@ func recordingLB(answer func(mbid string) string) (*httptest.Server, func() []st
} }
} }
// The starvation #3879 measured: a seed whose answer matched nothing in the // A seed whose answer matches nothing in the library writes no edges, and
// library wrote nothing, stayed due, and was asked again every tick ahead of // must still leave the head of the queue: recording the fetch moves it on,
// everything behind it. Recording the fetch moves the queue on. // so it is not asked again every tick ahead of everything behind it.
func TestTickOnce_UnmatchedAnswerDoesNotHoldTheQueue(t *testing.T) { func TestTickOnce_UnmatchedAnswerDoesNotHoldTheQueue(t *testing.T) {
f := newFixture(t) f := newFixture(t)
mbids := []string{ mbids := []string{
@@ -583,7 +583,7 @@ func TestTickOnce_UnmatchedAnswerDoesNotHoldTheQueue(t *testing.T) {
} }
} }
// The point of the cache (#5296): a recording LB named while it was not in the // The point of the cache: a recording LB named while it was not in the
// library becomes an edge once it arrives, on the next tick's resolve, without // library becomes an edge once it arrives, on the next tick's resolve, without
// asking LB again. One edge per recording when the library holds two copies, // asking LB again. One edge per recording when the library holds two copies,
// and none to a copy that is missing. // and none to a copy that is missing.
+8 -10
View File
@@ -26,8 +26,8 @@ type browseHandlers struct {
// for backwards compatibility with clients that regroup client-side. // for backwards compatibility with clients that regroup client-side.
const ignoredArticles = "The El La Los Las Le Les" const ignoredArticles = "The El La Los Las Le Les"
// getMusicFolders always returns a single folder. Minstrel has no multi-library // getMusicFolders always returns a single folder: Minstrel serves one library,
// concept in M1; clients use the folder id as an opaque cookie so we return 1. // and clients use the folder id as an opaque cookie, so it is 1.
func (b *browseHandlers) getMusicFolders(w http.ResponseWriter, r *http.Request) { func (b *browseHandlers) getMusicFolders(w http.ResponseWriter, r *http.Request) {
Write(w, r, MusicFoldersResponse{ Write(w, r, MusicFoldersResponse{
Envelope: NewEnvelope("ok"), Envelope: NewEnvelope("ok"),
@@ -236,9 +236,8 @@ func (b *browseHandlers) getSong(w http.ResponseWriter, r *http.Request) {
} }
// getAlbumList2 returns a paged album list filtered by type. Supported types // getAlbumList2 returns a paged album list filtered by type. Supported types
// match the Subsonic spec § getAlbumList2. "recent"/"frequent" require play // match the Subsonic spec § getAlbumList2. "recent" and "frequent" return an
// history (M2) and currently return an empty list rather than an error so // empty list rather than an error, so clients that poll them don't break.
// clients that poll them don't break.
func (b *browseHandlers) getAlbumList2(w http.ResponseWriter, r *http.Request) { func (b *browseHandlers) getAlbumList2(w http.ResponseWriter, r *http.Request) {
params := r.URL.Query() params := r.URL.Query()
listType := params.Get("type") listType := params.Get("type")
@@ -285,15 +284,14 @@ func (b *browseHandlers) getAlbumList2(w http.ResponseWriter, r *http.Request) {
WriteFail(w, r, ErrMissingParameter, "Missing required parameter: genre") WriteFail(w, r, ErrMissingParameter, "Missing required parameter: genre")
return return
} }
// Genre is a plain string as of #367 — the query now splits // The query splits tracks.genre on [;,] rather than comparing the
// tracks.genre on [;,] instead of comparing the whole column, so a // whole column, so a client asking for "Rock" also reaches tracks
// client asking for "Rock" also reaches tracks tagged "Rock;Pop". // tagged "Rock;Pop".
// Previously those were unreachable from either of their genres.
albums, err = q.ListAlbumsByGenre(r.Context(), dbq.ListAlbumsByGenreParams{ albums, err = q.ListAlbumsByGenre(r.Context(), dbq.ListAlbumsByGenreParams{
Genre: genre, Lim: int32(size), Off: int32(offset), Genre: genre, Lim: int32(size), Off: int32(offset),
}) })
case "recent", "frequent": case "recent", "frequent":
// Play history lands in M2; return empty to keep clients happy. // Not served from play history: empty keeps clients working.
albums = nil albums = nil
default: default:
WriteFail(w, r, ErrGeneric, "Invalid list type: "+listType) WriteFail(w, r, ErrGeneric, "Invalid list type: "+listType)
+1 -1
View File
@@ -11,7 +11,7 @@ import (
) )
// OpenSubsonic's replayGain is an object in JSON and an element with // OpenSubsonic's replayGain is an object in JSON and an element with
// attributes in XML. Clients parse both, so both are pinned (#4997). // attributes in XML. Clients parse both, so both are pinned.
func TestSongRef_ReplayGainEncodesForBothFormats(t *testing.T) { func TestSongRef_ReplayGainEncodesForBothFormats(t *testing.T) {
gain, peak := float32(-4.5), float32(0.8913) gain, peak := float32(-4.5), float32(0.8913)
tr := dbq.Track{Title: "Song", FilePath: "/m/a.flac", FileFormat: "flac"} tr := dbq.Track{Title: "Song", FilePath: "/m/a.flac", FileFormat: "flac"}
+2 -2
View File
@@ -40,7 +40,7 @@ func (m *mediaHandlers) handleStar(w http.ResponseWriter, r *http.Request) {
return return
} }
if trackID.Valid { if trackID.Valid {
// Stars the song: every copy of it (M498 #5438). // Stars the song: every copy of it.
liked, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID}) liked, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID})
if err != nil { if err != nil {
WriteFail(w, r, ErrGeneric, "Could not star track") WriteFail(w, r, ErrGeneric, "Could not star track")
@@ -182,7 +182,7 @@ func (m *mediaHandlers) handleGetStarred2(w http.ResponseWriter, r *http.Request
// loadStarred fetches the user's full starred set in liked_at DESC order, // loadStarred fetches the user's full starred set in liked_at DESC order,
// projects to Subsonic refs (Song / Album / Artist). // projects to Subsonic refs (Song / Album / Artist).
func loadStarred(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]SongRef, []AlbumRef, []ArtistRef, error) { func loadStarred(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]SongRef, []AlbumRef, []ArtistRef, error) {
const cap = 500 // bounded for safety; M2 doesn't paginate getStarred const cap = 500 // bounded for safety; getStarred is not paginated
songs := []SongRef{} songs := []SongRef{}
albums := []AlbumRef{} albums := []AlbumRef{}
artists := []ArtistRef{} artists := []ArtistRef{}
+1 -1
View File
@@ -181,7 +181,7 @@ type SongRef struct {
BitRate int `json:"bitRate,omitempty" xml:"bitRate,attr,omitempty"` BitRate int `json:"bitRate,omitempty" xml:"bitRate,attr,omitempty"`
IsDir bool `json:"isDir" xml:"isDir,attr"` IsDir bool `json:"isDir" xml:"isDir,attr"`
Type string `json:"type" xml:"type,attr"` Type string `json:"type" xml:"type,attr"`
// ReplayGain is OpenSubsonic's replayGain (M464 #4997): Minstrel's own // ReplayGain is OpenSubsonic's replayGain: Minstrel's own
// measurements, so third-party clients that level by ReplayGain do it // measurements, so third-party clients that level by ReplayGain do it
// from the same numbers the Minstrel apps use. Omitted until measured. // from the same numbers the Minstrel apps use. Omitted until measured.
ReplayGain *ReplayGain `json:"replayGain,omitempty" xml:"replayGain,omitempty"` ReplayGain *ReplayGain `json:"replayGain,omitempty" xml:"replayGain,omitempty"`
+2 -3
View File
@@ -52,9 +52,8 @@ func handleGetUser(w http.ResponseWriter, r *http.Request) {
WriteFail(w, r, ErrNotAuthorized, "Not authorized to view other users") WriteFail(w, r, ErrNotAuthorized, "Not authorized to view other users")
return return
} }
// We don't have a per-user lookup wired up yet; until then admins asking // No per-user lookup: an admin asking for another username is answered
// for another username see the caller's roles. Acceptable while M1 has a // with the caller's own roles.
// single user; revisit once user management lands.
Write(w, r, UserResponse{ Write(w, r, UserResponse{
Envelope: NewEnvelope("ok"), Envelope: NewEnvelope("ok"),
User: userPayload(caller), User: userPayload(caller),
+3 -3
View File
@@ -38,9 +38,9 @@ func LogChange(ctx context.Context, dbtx dbq.DBTX, entityType EntityType, entity
// LogChanges is LogChange for a set of entities of one type sharing one op. // LogChanges is LogChange for a set of entities of one type sharing one op.
// //
// Exists because the scan's reconcile pass can mark a quarter of a library // Exists because the scan's reconcile pass can mark a large part of a library
// missing in a single sweep (#2704), and a per-row loop there would be // missing in a single sweep, and a per-row loop there would be thousands of
// thousands of round-trips inside an operation that is already slow. Every // round-trips inside an operation that is already slow. Every
// other caller mutates one entity and should keep using LogChange. // other caller mutates one entity and should keep using LogChange.
// //
// A no-op on an empty set, so callers don't have to guard. // A no-op on an empty set, so callers don't have to guard.
+8 -11
View File
@@ -1,15 +1,12 @@
// Periodic compactor for the library_changes log (#357 deferred // Periodic compactor for the library_changes log. Every mutation in the
// follow-up). Every mutation in the library writes one row, so the // library writes one row, so the table grows unbounded without a retention
// table grows unbounded without a retention policy. This worker // policy. This worker trims rows older than CompactorRetention on a daily
// trims rows older than CompactorRetention on a daily tick. // tick.
// //
// Why daily / 30 days: Flutter clients track a per-app cursor and // Why 30 days: clients keep a cursor and pull deltas from the last seen id.
// pull deltas from the last seen id. As long as a client checks in // A client that checks in at least once per retention window never sees its
// at least once per retention window, it never sees the cursor go // cursor go stale; one offline longer than that gets the /api/library/sync
// stale. Clients that drop offline longer than that hit the /api/ // 410 and does a full resync.
// library/sync 410 fallback and do a full resync. 30 days is the
// established norm for similar offline-cache delta logs and matches
// the spec at docs/superpowers/specs/2026-05-09-flutter-offline-mode-design.md.
package sync package sync
+1 -1
View File
@@ -1,4 +1,4 @@
// Package sync supports the Flutter delta-sync endpoint by writing an // Package sync supports the delta-sync endpoint by writing an
// append-only library_changes log alongside every mutation on tracked // append-only library_changes log alongside every mutation on tracked
// entities (artists, albums, tracks, likes, playlists, playlist_tracks). // entities (artists, albums, tracks, likes, playlists, playlist_tracks).
// //
+3 -3
View File
@@ -93,7 +93,7 @@ func (e *Enricher) EnrichTrack(ctx context.Context, trackID pgtype.UUID, ref Tra
// tagFetch pairs a provider ID with a bound fetch call, so the merge-and- // tagFetch pairs a provider ID with a bound fetch call, so the merge-and-
// classify loop below is shared between the track chain and the candidate- // classify loop below is shared between the track chain and the candidate-
// artist chain (#2376) instead of being written twice with one word changed. // artist chain instead of being written twice with one word changed.
type tagFetch struct { type tagFetch struct {
providerID string providerID string
fetch func(context.Context) ([]Tag, error) fetch func(context.Context) ([]Tag, error)
@@ -241,7 +241,7 @@ func (e *Enricher) EnrichTrackBatch(ctx context.Context, limit int,
} }
// EnrichCandidateArtist runs the artist-tag chain for one out-of-library // EnrichCandidateArtist runs the artist-tag chain for one out-of-library
// Discover candidate and caches the merged result (#2376). // Discover candidate and caches the merged result.
// //
// Mirrors EnrichTrack, with one deliberate difference in the transient case: // Mirrors EnrichTrack, with one deliberate difference in the transient case:
// there is no row to "leave NULL", because eligibility is the ABSENCE of a // there is no row to "leave NULL", because eligibility is the ABSENCE of a
@@ -381,7 +381,7 @@ func (e *Enricher) logCandidateBatchSummary(eligible, processed, enriched, settl
} }
// logBatchSummary emits one Info line with the category breakdown — the // logBatchSummary emits one Info line with the category breakdown — the
// enriched/settled/left-null split is the operator's diagnostic for a // enriched/settled/left-null split is the diagnostic for a
// "0 enriched" symptom the collapsed tally can't explain. // "0 enriched" symptom the collapsed tally can't explain.
func (e *Enricher) logBatchSummary(eligible, processed, enriched, settledNone, leftNull, errored int) { func (e *Enricher) logBatchSummary(eligible, processed, enriched, settledNone, leftNull, errored int) {
e.logger.Info("tags: enrichment batch complete", e.logger.Info("tags: enrichment batch complete",
+1 -1
View File
@@ -9,7 +9,7 @@ import (
) )
// runChain is the merge-and-classify loop shared by the track and // runChain is the merge-and-classify loop shared by the track and
// candidate-artist drains (#2376). It touches no DB, so it is testable with a // candidate-artist drains. It touches no DB, so it is testable with a
// bare Enricher. // bare Enricher.
// //
// The classification is the part that matters: "every source cleanly had // The classification is the part that matters: "every source cleanly had
+6 -7
View File
@@ -1,5 +1,5 @@
// Package tags enriches the taste profile's tag facet beyond raw ID3 // Package tags enriches the taste profile's tag facet beyond raw ID3
// genre (milestone #160, task #1490). It fetches folksonomy style/mood // genre. It fetches folksonomy style/mood
// tags for tracks from a chain of pluggable providers (MusicBrainz, // tags for tracks from a chain of pluggable providers (MusicBrainz,
// Last.fm, …), caches them in track_tags, and the taste recompute unions // Last.fm, …), caches them in track_tags, and the taste recompute unions
// them alongside tracks.genre. // them alongside tracks.genre.
@@ -33,7 +33,7 @@ type Tag struct {
// TrackRef is the lookup key passed to a provider. MBID is the track's // TrackRef is the lookup key passed to a provider. MBID is the track's
// recording MBID (preferred by MBID-keyed providers); ArtistName + Title // recording MBID (preferred by MBID-keyed providers); ArtistName + Title
// are the always-populated fallback for name-based providers (Last.fm); // are the always-populated fallback for name-based providers (Last.fm);
// ArtistMBID (nullable) enables MusicBrainz's artist-tag fallback (#1519) // ArtistMBID (nullable) enables MusicBrainz's artist-tag fallback
// when the recording itself is untagged or MBID-less. // when the recording itself is untagged or MBID-less.
type TrackRef struct { type TrackRef struct {
MBID string MBID string
@@ -55,7 +55,7 @@ type Provider interface {
// RequiresAPIKey reports whether the provider needs a key to function. // RequiresAPIKey reports whether the provider needs a key to function.
// The admin UI shows/hides the key field on this; it does NOT gate the // The admin UI shows/hides the key field on this; it does NOT gate the
// enabled toggle (rule #26 — providers with usable defaults ship working). // enabled toggle: a provider with usable defaults ships working.
RequiresAPIKey() bool RequiresAPIKey() bool
// DefaultEnabled is whether a freshly-installed Minstrel ships with // DefaultEnabled is whether a freshly-installed Minstrel ships with
@@ -63,7 +63,7 @@ type Provider interface {
// (keyed) returns false — opt-in once a key is supplied. // (keyed) returns false — opt-in once a key is supplied.
DefaultEnabled() bool DefaultEnabled() bool
// Configure applies the operator's settings. Called at boot and on // Configure applies the admin's settings. Called at boot and on
// every admin PATCH; must be idempotent and goroutine-safe (the // every admin PATCH; must be idempotent and goroutine-safe (the
// enricher may read concurrently). // enricher may read concurrently).
Configure(ProviderSettings) error Configure(ProviderSettings) error
@@ -96,9 +96,8 @@ type ArtistRef struct {
Name string Name string
} }
// ArtistTagProvider is the artist-level tag capability, added for tag-space // ArtistTagProvider is the artist-level tag capability, used by tag-space
// Discover (#2376). Separate from TrackTagProvider — exactly the split that // Discover. Separate from TrackTagProvider, so a source can implement either
// interface's doc comment anticipated — so a source can implement either
// without the other, and so the enricher can ask for the capability it needs // without the other, and so the enricher can ask for the capability it needs
// rather than checking at the call site. // rather than checking at the call site.
// //
+3 -3
View File
@@ -8,7 +8,7 @@ import (
"testing" "testing"
) )
// Artist-level tag fetching for out-of-library Discover candidates (#2376). // Artist-level tag fetching for out-of-library Discover candidates.
// Reuses the mbEntityServer / newMBProvider / newLastfmProvider helpers from // Reuses the mbEntityServer / newMBProvider / newLastfmProvider helpers from
// the per-provider test files. // the per-provider test files.
@@ -16,8 +16,8 @@ func TestMusicBrainzFetchArtistTags_UsesFullWeightNotTheFallbackDiscount(t *test
// THE distinction worth a test. artistTagWeightFactor (0.6) exists because // THE distinction worth a test. artistTagWeightFactor (0.6) exists because
// FetchTrackTags uses artist tags as a *proxy* for a track's tags. Here the // FetchTrackTags uses artist tags as a *proxy* for a track's tags. Here the
// artist IS the subject, so weights must land unscaled — otherwise these // artist IS the subject, so weights must land unscaled — otherwise these
// are not comparable with track_tags, which is the exact comparison slice 6 // are not comparable with track_tags, which is the comparison tag-space
// is built on. // Discover is built on.
srv := mbEntityServer(``, `{"tags":[{"count":4,"name":"shoegaze"},{"count":2,"name":"dream pop"}]}`) srv := mbEntityServer(``, `{"tags":[{"count":4,"name":"shoegaze"},{"count":2,"name":"dream pop"}]}`)
defer srv.Close() defer srv.Close()
old := mbBaseURL old := mbBaseURL
+1 -1
View File
@@ -122,7 +122,7 @@ func (p *lastfmProvider) FetchTrackTags(ctx context.Context, ref TrackRef) ([]Ta
} }
// FetchArtistTags looks up an artist's top tags by name, with the MBID as an // FetchArtistTags looks up an artist's top tags by name, with the MBID as an
// extra hint when present (#2376). Name-first is deliberate and the opposite // extra hint when present. Name-first is deliberate and the opposite
// emphasis from MusicBrainz: Last.fm's tag data is keyed on its own artist // emphasis from MusicBrainz: Last.fm's tag data is keyed on its own artist
// pages, and autocorrect resolves most spelling drift from the similarity feed. // pages, and autocorrect resolves most spelling drift from the similarity feed.
// //

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