docs: api comments say what the endpoints do, not how they came to be (#6277)

Rewrites the internal/api comment blocks to rule 32: task, milestone and
rule ids gone, the stories behind them restated as the constraint they
left, stale references corrected (the stream secret is wired from config,
the timezone handler already refreshes the scheduler, the Android app is
the native client, the design docs cited are not in the repo). Also
rewraps comment lines over 80 columns left by the earlier batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 12:23:58 -04:00
co-authored by Claude Opus 5.5
parent fcf5b7e5a8
commit 08a2c06b85
102 changed files with 518 additions and 522 deletions
+2 -2
View File
@@ -39,8 +39,8 @@ const (
// minInterval keeps to AcoustID's 3 requests a second with headroom: one
// every 400ms is 2.5 a second.
minInterval = 400 * time.Millisecond
// lookupTimeout bounds one request, connection included. A lookup answers in well under a second; 20s is the line past which slow
// has become never.
// lookupTimeout bounds one request, connection included. A lookup answers
// in well under a second; 20s is the line past which slow has become never.
lookupTimeout = 20 * time.Second
// bodyLimit caps the response read. A lookup with meta=recordings for a
// popular song runs to tens of kilobytes.
+2 -2
View File
@@ -10,8 +10,8 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
)
// Searching every source at once. A source that is down narrows a search rather than failing it: the
// results come back with the list of who did not answer.
// Searching every source at once. A source that is down narrows a search rather
// than failing it: the results come back with the list of who did not answer.
// SourceFailure is one source that could not be reached.
type SourceFailure struct {
+3 -2
View File
@@ -8,8 +8,9 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
)
// SearchPace is the one Pacer every search shares. Process-wide because a Fanout is built fresh for each
// search; a Pacer per Fanout would start empty and pace nothing.
// SearchPace is the one Pacer every search shares. Process-wide because a
// Fanout is built fresh for each search; a Pacer per Fanout would start empty
// and pace nothing.
var SearchPace = &Pacer{}
// holdWithoutRetryAfter is how long a source that answered 429 without saying
+6 -5
View File
@@ -11,7 +11,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
// AcoustID recording-id lookup, admin side (M401 #3922).
// AcoustID recording-id lookup, admin side.
// acoustIDUnsettledLimit caps the admin list of tracks a lookup could not
// settle. It is a list to read, not to page through.
@@ -96,7 +96,8 @@ type acoustIDSettingsReq struct {
APIKey *string `json:"api_key"`
}
// handleUpdateAcoustIDSettings implements PUT /api/admin/library/acoustid-settings.
// handleUpdateAcoustIDSettings implements PUT
// /api/admin/library/acoustid-settings.
func (h *handlers) handleUpdateAcoustIDSettings(w http.ResponseWriter, r *http.Request) {
var req acoustIDSettingsReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -143,9 +144,9 @@ type acoustIDUnsettledResp struct {
LookedUpAt time.Time `json:"looked_up_at"`
}
// handleListUnsettledAcoustID implements GET /api/admin/library/acoustid/unsettled:
// the tracks a lookup could not settle (ambiguous or failed), so they can be
// found and tagged by hand.
// handleListUnsettledAcoustID implements GET
// /api/admin/library/acoustid/unsettled: the tracks a lookup could not settle
// (ambiguous or failed), so they can be found and tagged by hand.
func (h *handlers) handleListUnsettledAcoustID(w http.ResponseWriter, r *http.Request) {
rows, err := dbq.New(h.pool).ListUnsettledAcoustIDLookups(r.Context(), acoustIDUnsettledLimit)
if err != nil {
+5 -4
View File
@@ -13,7 +13,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Indexers and download clients, admin side (M502 #5518). Rows the operator
// Indexers and download clients, admin side. Rows an admin
// adds and configures; nothing is seeded. Secrets are write-only: no response
// carries one, only whether it is set.
@@ -79,9 +79,10 @@ func (h *handlers) handleListAcquisitionServices(w http.ResponseWriter, r *http.
writeJSON(w, http.StatusOK, map[string]any{"services": out})
}
// handleCreateAcquisitionService implements POST /api/admin/acquisition/services
// with {"protocol": "prowlarr"|"torznab"|"qbittorrent"|"exchange"}. The row
// starts switched off, with no address.
// handleCreateAcquisitionService implements POST
// /api/admin/acquisition/services with {"protocol":
// "prowlarr"|"torznab"|"qbittorrent"|"exchange"}. The row starts switched off,
// with no address.
func (h *handlers) handleCreateAcquisitionService(w http.ResponseWriter, r *http.Request) {
var body struct {
Protocol string `json:"protocol"`
+4 -2
View File
@@ -70,7 +70,8 @@ type updateCoverSourceResp struct {
VersionBumped bool `json:"version_bumped"`
}
// handleUpdateCoverSource implements PATCH /api/admin/cover-sources/{provider_id}.
// handleUpdateCoverSource implements PATCH
// /api/admin/cover-sources/{provider_id}.
func (h *handlers) handleUpdateCoverSource(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "provider_id")
@@ -128,7 +129,8 @@ type testCoverSourceResp struct {
Error string `json:"error,omitempty"`
}
// handleTestCoverSource implements POST /api/admin/cover-sources/{provider_id}/test.
// handleTestCoverSource implements POST
// /api/admin/cover-sources/{provider_id}/test.
func (h *handlers) handleTestCoverSource(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "provider_id")
+2 -1
View File
@@ -103,7 +103,8 @@ func TestAdminListCoverSources_ReturnsRegisteredProviders(t *testing.T) {
if p.APIKeySet {
t.Error("api_key_set = true, want false (no key set)")
}
// apiTestAlbumProvider implements AlbumCoverProvider but not TestableProvider.
// apiTestAlbumProvider implements AlbumCoverProvider but not
// TestableProvider.
if p.Testable {
t.Error("testable = true, want false")
}
+7 -6
View File
@@ -39,11 +39,12 @@ func (h *handlers) handleGetLibraryCoverage(w http.ResponseWriter, r *http.Reque
})
}
// fingerprintCoverageResp is the wire shape for GET /api/admin/library/fingerprints.
// fingerprinted + rejected + pending = total. Missing tracks are not counted:
// there is no file to fingerprint. Enabled travels with the counts because with
// fingerprinting off (#3913) pending never shrinks, and a gauge that implies
// progress would be promising work nothing is doing.
// fingerprintCoverageResp is the wire shape for GET
// /api/admin/library/fingerprints. fingerprinted + rejected + pending = total.
// Missing tracks are not counted: there is no file to fingerprint. Enabled
// travels with the counts because with fingerprinting off pending never
// shrinks, and a gauge that implies progress would be promising work nothing is
// doing.
type fingerprintCoverageResp struct {
Total int64 `json:"total"`
Fingerprinted int64 `json:"fingerprinted"`
@@ -70,7 +71,7 @@ func (h *handlers) fingerprintCoverage(ctx context.Context) (fingerprintCoverage
}
// handleGetFingerprintCoverage implements GET /api/admin/library/fingerprints:
// how far the fingerprint backfill (#3908) has got. The backfill is its own
// how far the fingerprint backfill has got. The backfill is its own
// worker spanning many passes, with no scan run to attach a tally to, so its
// progress is read live here. Always 200; zeros on an empty library.
func (h *handlers) handleGetFingerprintCoverage(w http.ResponseWriter, r *http.Request) {
+3 -5
View File
@@ -80,11 +80,9 @@ func TestAdminLibraryCoverage_MixedRowsReturnCorrectBuckets(t *testing.T) {
mbid5 := "55555555-5555-5555-5555-555555555555"
mbid6 := "66666666-6666-6666-6666-666666666666"
mbid7 := "77777777-7777-7777-7777-777777777777"
// Cover every cover_art_source value the migrations allow so a future
// addition without updating the rollup query trips this test —
// regression guard for drift #557, the gap that hid #556 (deezer +
// lastfm omission introduced by migration 0020 but never wired into
// the rollup whitelist).
// Cover every cover_art_source value the migrations allow, so a source
// added to the CHECK but not to the rollup query's whitelist trips this
// test.
rows := []seed{
{title: "WithArtSidecar", source: &sourceSidecar, mbid: &mbid1}, // with_art
{title: "WithArtMbcaa", source: &sourceMbcaa, mbid: &mbid2}, // with_art
+6 -5
View File
@@ -19,8 +19,9 @@ type adminCoverRefetchResp struct {
CoverArtSource *string `json:"cover_art_source"`
}
// handleAdminAlbumRefetchCover implements POST /api/admin/albums/{id}/cover/refetch.
// Synchronously clears + re-runs the enricher for one album.
// handleAdminAlbumRefetchCover implements POST
// /api/admin/albums/{id}/cover/refetch. Synchronously clears + re-runs the
// enricher for one album.
func (h *handlers) handleAdminAlbumRefetchCover(w http.ResponseWriter, r *http.Request) {
albumID, ok := parseUUID(chi.URLParam(r, "id"))
if !ok {
@@ -51,9 +52,9 @@ type adminBulkRefetchResp struct {
Started bool `json:"started"`
}
// handleAdminBulkRefetchCovers implements POST /api/admin/covers/refetch-missing.
// Returns immediately; the drain runs UNBOUNDED in a background goroutine
// (#388 — no global cap; remote providers self-throttle per provider, local
// handleAdminBulkRefetchCovers implements POST
// /api/admin/covers/refetch-missing. Returns immediately; the drain runs
// UNBOUNDED in a background goroutine (remote providers pace themselves, local
// sources run at disk speed). The count is unknowable synchronously, so the
// response just acknowledges the drain started.
func (h *handlers) handleAdminBulkRefetchCovers(w http.ResponseWriter, _ *http.Request) {
+21 -19
View File
@@ -18,7 +18,7 @@ import (
// duplicateMemberView is one copy in a proposed duplicate group. LikeCount and
// PlayCount span every user: the report is admin-only, and what a copy carries
// is the fact the operator weighs when choosing which to keep.
// is the fact an admin weighs when choosing which to keep.
type duplicateMemberView struct {
TrackID string `json:"track_id"`
Title string `json:"title"`
@@ -36,13 +36,13 @@ type duplicateMemberView struct {
DiscNumber *int32 `json:"disc_number"`
TrackNumber *int32 `json:"track_number"`
// StreamURL lets the review play each copy, to hear whether they are the
// same song (#6168).
// same song.
StreamURL string `json:"stream_url"`
}
// duplicateGroupView is one proposal. SurvivorTrackID and SurvivorReason are
// the copy the report proposes keeping and the rule that chose it
// (library.ProposeSurvivor) — a default the merge (#3911) lets the operator
// (library.ProposeSurvivor) — a default the merge lets an admin
// override.
type duplicateGroupView struct {
ID string `json:"id"`
@@ -52,7 +52,7 @@ type duplicateGroupView struct {
SurvivorTrackID string `json:"survivor_track_id"`
SurvivorReason string `json:"survivor_reason"`
// Class is the resolver's verdict (library.DuplicateClass), null until its
// first pass; ResolveNote is why it left the group for the operator.
// first pass; ResolveNote is why it left the group for an admin.
Class *string `json:"class"`
ResolveNote *string `json:"resolve_note"`
Members []duplicateMemberView `json:"members"`
@@ -78,8 +78,8 @@ type duplicateViewCounts struct {
Resolved int64 `json:"resolved"`
}
// Report views (M498). cross_release groups are the same song on different
// releases, kept on purpose; everything else pending needs the operator.
// Report views. cross_release groups are the same song on different
// releases, kept on purpose; everything else pending needs an admin.
const (
duplicateViewReview = "review"
duplicateViewCrossRelease = "cross_release"
@@ -97,12 +97,13 @@ type adminDuplicatesResponse struct {
Groups []duplicateGroupView `json:"groups"`
}
// handleListDuplicates implements GET /api/admin/library/duplicates (#3912).
// ?view=review (the default) or cross_release picks the tab (M498).
// handleListDuplicates implements GET /api/admin/library/duplicates.
// ?view=review (the default) or cross_release picks the tab.
//
// Read-only. The sweep's state and the fingerprint backfill's progress travel
// with the groups because an empty report means three different things — still
// fingerprinting, never swept, or swept and clean — and the page has to say which.
// fingerprinting, never swept, or swept and clean — and the page has to say
// which.
func (h *handlers) handleListDuplicates(w http.ResponseWriter, r *http.Request) {
limit, offset, err := parsePaging(r.URL.Query())
if err != nil {
@@ -203,7 +204,7 @@ type resolvedActivityResponse struct {
}
// handleListResolvedDuplicates implements GET
// /api/admin/library/duplicates/resolved (M498): what the resolver did by
// /api/admin/library/duplicates/resolved: what the resolver did by
// itself, newest first, read from the audit log so it outlives the groups.
func (h *handlers) handleListResolvedDuplicates(w http.ResponseWriter, r *http.Request) {
limit, offset, err := parsePaging(r.URL.Query())
@@ -332,7 +333,7 @@ func (h *handlers) handleRunDuplicateSweep(w http.ResponseWriter, _ *http.Reques
// handleDismissDuplicateGroup implements POST
// /api/admin/library/duplicates/{id}/dismiss: "these are not duplicates". The
// sweep keeps the dismissal and will not propose that set of tracks again. On a
// cross-release group it also undoes the song link (M498 #5438): the copies
// cross-release group it also undoes the song link: the copies
// stop sharing likes from here on. 404 duplicate_group_not_pending when the
// group was already resolved or is gone.
func (h *handlers) handleDismissDuplicateGroup(w http.ResponseWriter, r *http.Request) {
@@ -380,7 +381,7 @@ type mergeDuplicateRequest struct {
}
// mergeDuplicateResponse reports what the merge removed. RemovedPaths are files
// deleted from disk; the operator reads them to know exactly what went.
// deleted from disk; an admin reads them to know exactly what went.
type mergeDuplicateResponse struct {
SurvivorTrackID string `json:"survivor_track_id"`
RemovedPaths []string `json:"removed_paths"`
@@ -389,9 +390,10 @@ type mergeDuplicateResponse struct {
// mergeRequestBodyLimit bounds the request body. It holds one id.
const mergeRequestBodyLimit = 1 << 16
// handleMergeDuplicateGroup implements POST /api/admin/library/duplicates/{id}/merge
// (#3911): keep one copy, move the others' likes, plays and playlist entries onto
// it, and delete the others' files and rows.
// handleMergeDuplicateGroup implements POST
// /api/admin/library/duplicates/{id}/merge: keep one copy, move the others'
// likes, plays and playlist entries onto it, and delete the others' files and
// rows.
//
// Errors:
// - 409 library_not_writable / 500 file_delete_failed when a file could not be
@@ -450,7 +452,7 @@ func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Requ
writeJSON(w, http.StatusOK, resp)
}
// refetchDuplicateRequest is the operator's answer for one recording filling
// refetchDuplicateRequest is an admin's answer for one recording filling
// several of an album's slots. keep_track_id is the copy whose title the
// recording is; empty means they cannot say, and every copy goes.
type refetchDuplicateRequest struct {
@@ -466,9 +468,9 @@ type refetchDuplicateResponse struct {
}
// handleRefetchDuplicateGroup implements POST
// /api/admin/library/duplicates/{id}/refetch (#6184): settle a group of one
// recording in several slots of an album the way the resolver would, with the
// operator's answer. The copies in slots that are not the recording's are
// /api/admin/library/duplicates/{id}/refetch: settle a group of one
// recording in several slots of an album the way the resolver would, with an
// admin's answer. The copies in slots that are not the recording's are
// removed and marked missing, the release they came from is set aside, and the
// album is wanted again. Merge is the wrong action there: it keeps one title
// whether or not it is the audio's, and fetches nothing.
+1 -1
View File
@@ -64,7 +64,7 @@ func TestFoldDuplicateGroups(t *testing.T) {
}
}
// M498: the proposal shown beside a group follows the resolver's rule. The copy
// the proposal shown beside a group follows the resolver's rule. The copy
// whose tags fit the album is proposed even when the other is larger (only a
// lossless copy outranks it).
func TestFoldDuplicateGroups_ProposesTheCopyWhoseTagsFit(t *testing.T) {
+10 -8
View File
@@ -10,15 +10,15 @@ import (
)
// fingerprintSettingsBody is the wire shape for GET and PUT
// /api/admin/library/fingerprint-settings (M400 #3913). The threshold travels as
// the bit-error rate the matcher uses; the card presents it as a match percentage.
// /api/admin/library/fingerprint-settings. The threshold travels as the
// bit-error rate the matcher uses; the card presents it as a match percentage.
type fingerprintSettingsBody struct {
Enabled bool `json:"enabled"`
ChromaprintLengthSec int32 `json:"chromaprint_length_sec"`
AcousticMaxBitErrorRate float64 `json:"acoustic_max_bit_error_rate"`
BackfillConcurrency int32 `json:"backfill_concurrency"`
SweepIntervalHours int32 `json:"sweep_interval_hours"`
// AutoResolve is a pointer so a client that predates it (M498) and leaves
// AutoResolve is a pointer so a client that predates it and leaves
// it out keeps the saved value rather than switching the resolver off.
AutoResolve *bool `json:"auto_resolve"`
}
@@ -34,16 +34,18 @@ func fingerprintSettingsBodyOf(s library.FingerprintSettings) fingerprintSetting
}
}
// handleGetFingerprintSettings implements GET /api/admin/library/fingerprint-settings.
// handleGetFingerprintSettings implements GET
// /api/admin/library/fingerprint-settings.
func (h *handlers) handleGetFingerprintSettings(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, fingerprintSettingsBodyOf(h.fingerprintSettings.Get()))
}
// handleUpdateFingerprintSettings implements PUT /api/admin/library/fingerprint-settings.
// handleUpdateFingerprintSettings implements PUT
// /api/admin/library/fingerprint-settings.
//
// A whole-row write. A body that leaves a field out decodes it as zero, which no
// field accepts, so a partial save is refused rather than zeroing what it omitted.
// auto_resolve is the exception: left out, it keeps its saved value.
// A whole-row write. A body that leaves a field out decodes it as zero, which
// no field accepts, so a partial save is refused rather than zeroing what it
// omitted. auto_resolve is the exception: left out, it keeps its saved value.
// The saved settings reach the scanner and both workers at once: they share the
// service instance.
func (h *handlers) handleUpdateFingerprintSettings(w http.ResponseWriter, r *http.Request) {
+4 -4
View File
@@ -13,7 +13,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/lifecycle"
)
// Downloads and their imports (M502 #5522): the Wanted view's Activity tab,
// Downloads and their imports: the Wanted view's Activity tab,
// a person's "import anyway", "refuse" and "retry", and the Blocked tab.
// downloadView is one grab and what its import made of it.
@@ -41,7 +41,7 @@ type downloadView struct {
Files []string `json:"files"`
Skipped int `json:"skipped"`
RehomedFrom string `json:"rehomed_from,omitempty"`
// EndDetail says why an ended download ended (#5523).
// EndDetail says why an ended download ended.
EndDetail string `json:"end_detail"`
// Seeding: imported and still sharing; Ratio its share ratio as last
// read; SeedReleasedAt when it was removed once it had shared enough.
@@ -113,7 +113,7 @@ type downloadsPage struct {
Items []downloadView `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
// ClientsDown are the download clients not answering now (#5523).
// ClientsDown are the download clients not answering now.
ClientsDown []clientDownView `json:"clients_down"`
}
@@ -239,7 +239,7 @@ type releaseBlockView struct {
Detail string `json:"detail"`
BlockedAt string `json:"blocked_at"`
// ExpiresAt is when a release given up on may be taken again; null is
// for good (#5523).
// for good.
ExpiresAt *string `json:"expires_at"`
WantTitle string `json:"want_title"`
WantArtist string `json:"want_artist"`
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
// The library's folders (#6129): where the scan, the watcher and the import
// The library's folders: where the scan, the watcher and the import
// look for music, set here rather than only by MINSTREL_LIBRARY_SCAN_PATHS.
type libraryFolderView struct {
+11 -11
View File
@@ -29,12 +29,12 @@ type missingTrackView struct {
LastPlayedAt *string `json:"last_played_at"`
}
// reacquisitionStateView is what the wanted list says about one album
// (milestone #290, M502 #5520): when it was wanted back, and when the library
// reacquisitionStateView is what the wanted list says about one album: when
// it was wanted back, and when the library
// held it again. A missing album with no want yet is still inside the grace
// window, or MusicBrainz cannot name it. Want is that want as the wanted list
// sends it, so the Missing page can show its search and download and act on
// it (M504).
// it.
type reacquisitionStateView struct {
WantedAt *string `json:"wanted_at"`
HeldAt *string `json:"held_at"`
@@ -43,11 +43,11 @@ type reacquisitionStateView struct {
// missingGroupView is a directory's worth of missing tracks.
//
// Grouping is the whole ergonomic argument for this surface. The case that
// produced #2523 was three reorganised albums showing up as ~40 individually
// missing files; presented flat that reads as forty problems, presented by
// folder it reads as three. MissingSince is the EARLIEST mark in the group,
// so a directory sorts and reads by when it first went away.
// Grouping is the whole ergonomic argument for this surface: a reorganised
// album shows up as dozens of individually missing files, which presented flat
// reads as dozens of problems and presented by folder reads as one.
// MissingSince is the EARLIEST mark in the group, so a directory sorts and
// reads by when it first went away.
type missingGroupView struct {
Directory string `json:"directory"`
MissingSince string `json:"missing_since"`
@@ -72,8 +72,8 @@ type adminMissingResponse struct {
//
// Read-only by design. Nothing on this surface deletes a track: a missing file
// keeps its row, its history and its likes because it may come back, and if it
// comes back renamed the scanner adopts it (#2528). The surface exists so an
// operator can SEE what the library has lost and act on it deliberately.
// comes back renamed the scanner adopts it. The surface exists so an admin
// can SEE what the library has lost and act on it deliberately.
func (h *handlers) handleListMissingTracks(w http.ResponseWriter, r *http.Request) {
limit, offset, err := parsePaging(r.URL.Query())
if err != nil {
@@ -215,7 +215,7 @@ func (h *handlers) attachReacquisitionState(
continue
}
// Held before its files went (every library album is on the list,
// held, since #5567): not wanted back yet, which the sweep does once
// held): not wanted back yet, which the sweep does once
// the grace window has passed.
if w.FulfilledAt.Valid && !w.FulfilledAt.Time.After(dirLost[groups[i].Directory]) {
continue
@@ -38,8 +38,8 @@ func seedMissingWant(t *testing.T, h *handlers, trackID, albumID pgtype.UUID, gr
}
// A folder's album that was held before its files went is not wanted back
// yet (every library album is on the list, held, since #5567); an open want
// comes with its search, so the Missing page can show it and act on it (M504).
// yet (every library album is on the list, held); an open want comes with
// its search, so the Missing page can show it and act on it.
func TestListMissingTracks_WantState(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
+8 -7
View File
@@ -9,9 +9,9 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
// loudnessCoverageResp is the wire shape for GET /api/admin/library/loudness
// (M464 #4995). measured + silent + unreadable + pending = total; missing
// tracks are not counted. Enabled travels with the counts because with
// loudnessCoverageResp is the wire shape for GET /api/admin/library/loudness.
// measured + silent + unreadable + pending = total; missing tracks are not
// counted. Enabled travels with the counts because with
// analysis off, pending never shrinks, and a gauge implying progress would be
// promising work nothing is doing.
type loudnessCoverageResp struct {
@@ -57,14 +57,15 @@ func loudnessSettingsBodyOf(s library.LoudnessSettings) loudnessSettingsBody {
}
}
// handleGetLoudnessSettings implements GET /api/admin/library/loudness-settings.
// handleGetLoudnessSettings implements GET
// /api/admin/library/loudness-settings.
func (h *handlers) handleGetLoudnessSettings(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, loudnessSettingsBodyOf(h.loudnessSettings.Get()))
}
// handleUpdateLoudnessSettings implements PUT /api/admin/library/loudness-settings.
// A whole-row write: a body that leaves out the concurrency decodes it as zero,
// which is refused rather than saved.
// handleUpdateLoudnessSettings implements PUT
// /api/admin/library/loudness-settings. A whole-row write: a body that leaves
// out the concurrency decodes it as zero, which is refused rather than saved.
func (h *handlers) handleUpdateLoudnessSettings(w http.ResponseWriter, r *http.Request) {
var req loudnessSettingsBody
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+2 -2
View File
@@ -14,7 +14,7 @@ type networkSettingsResp struct {
TrustedProxyHops int `json:"trusted_proxy_hops"`
MaxHops int `json:"max_hops"`
// DetectedClientIP is what the CURRENT setting resolves this very request
// to. It's the difference between a number the operator has to reason
// to. It's the difference between a number an admin has to reason
// about and one they can verify: set the value, reload, and check the
// address matches the machine you're sitting at.
DetectedClientIP string `json:"detected_client_ip"`
@@ -79,7 +79,7 @@ func (h *handlers) handleUpdateNetworkSettings(w http.ResponseWriter, r *http.Re
}
// Echo the payload recomputed under the NEW value, so the card can show
// immediately what the change did to this request's own address rather
// than making the operator reload to find out.
// than making an admin reload to find out.
writeJSON(w, http.StatusOK, h.networkSettingsPayload(r))
}
+1 -1
View File
@@ -11,7 +11,7 @@ import (
)
// notificationEmailBody is the wire shape for GET and PUT
// /api/admin/notification-email (M489 #5346): when the grouped emails go out.
// /api/admin/notification-email: when the grouped emails go out.
type notificationEmailBody struct {
SummaryHour int32 `json:"summary_hour"`
BatchWindowMinutes int32 `json:"batch_window_minutes"`
+6 -4
View File
@@ -85,7 +85,8 @@ type actionResultView struct {
AffectedUsers int32 `json:"affected_users"`
}
// handleResolveQuarantine implements POST /api/admin/quarantine/{track_id}/resolve.
// handleResolveQuarantine implements POST
// /api/admin/quarantine/{track_id}/resolve.
func (h *handlers) handleResolveQuarantine(w http.ResponseWriter, r *http.Request) {
admin, ok := auth.UserFromContext(r.Context())
if !ok {
@@ -115,7 +116,8 @@ func (h *handlers) handleResolveQuarantine(w http.ResponseWriter, r *http.Reques
})
}
// handleDeleteQuarantineFile implements POST /api/admin/quarantine/{track_id}/delete-file.
// handleDeleteQuarantineFile implements POST
// /api/admin/quarantine/{track_id}/delete-file.
func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Request) {
admin, ok := auth.UserFromContext(r.Context())
if !ok {
@@ -129,8 +131,8 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req
}
action, err := h.quarantine.DeleteFile(r.Context(), id, admin.ID)
if err != nil {
// Written in the enveloped shape, not writeAdminJSONErr's bare code: the
// message is the part that tells the operator which directory and uid.
// Written in the enveloped shape, not writeAdminJSONErr's bare code:
// the message is the part that tells an admin which directory and uid.
if apiErr, ok := fileRemoveAPIError(err); ok {
logFileRemoveFailure(h.logger, apiErr, "track_id", uuidToString(id))
writeErr(w, apiErr)
+6 -4
View File
@@ -10,12 +10,12 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
)
// reacquisitionSettingsResp is the admin card's payload (milestone #290).
// reacquisitionSettingsResp is the admin card's payload.
//
// Carries more than the stored settings: [UnnameableAlbums] is the count of
// albums with missing files that can never be wanted automatically because
// neither they nor their artist has an MBID. The card states that number
// rather than leaving the operator to wonder why some rows never get wanted —
// rather than leaving an admin to wonder why some rows never get wanted —
// a feature that silently does nothing for part of its input reads as broken.
type reacquisitionSettingsResp struct {
Enabled bool `json:"enabled"`
@@ -25,12 +25,14 @@ type reacquisitionSettingsResp struct {
UnnameableAlbums int64 `json:"unnameable_albums"`
}
// handleGetReacquisitionSettings implements GET /api/admin/library/reacquisition.
// handleGetReacquisitionSettings implements GET
// /api/admin/library/reacquisition.
func (h *handlers) handleGetReacquisitionSettings(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.reacquisitionPayload(r))
}
// handleUpdateReacquisitionSettings implements PUT /api/admin/library/reacquisition.
// handleUpdateReacquisitionSettings implements PUT
// /api/admin/library/reacquisition.
func (h *handlers) handleUpdateReacquisitionSettings(w http.ResponseWriter, r *http.Request) {
var req reacquisitionSettingsResp
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+3 -3
View File
@@ -1,6 +1,6 @@
// Admin recommendation-trends endpoint (#1251): weekly per-surface
// outcome series with tuning-audit markers — the verify half of the
// tune→verify loop the tuning lab (#1250) opens. Aggregated across
// Admin recommendation-trends endpoint: weekly per-surface outcome
// series with tuning-audit markers — the verify half of the tune→verify
// loop the tuning card opens. Aggregated across
// all users because the knobs are global; rows carry rates only.
package api
+6 -5
View File
@@ -1,8 +1,8 @@
// Admin recommendation-tuning endpoints (#1250): the defaults-
// discovery lab. GET returns current values + shipped defaults for
// Admin recommendation-tuning endpoints: the lab for finding good
// defaults. GET returns current values + shipped defaults for
// every scope; PATCH applies a partial update to one scope; reset
// restores a scope to shipped defaults. Every change writes an audit
// row (consumed by the metrics trend view, #1251).
// row (consumed by the metrics trend view).
package api
import (
@@ -67,7 +67,7 @@ func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
}
}
// discoverTuningResp is the Discover scope on the wire (#2377).
// discoverTuningResp is the Discover scope on the wire.
type discoverTuningResp struct {
TagOverlapWeight float64 `json:"tag_overlap_weight"`
SnoozeDays float64 `json:"snooze_days"`
@@ -113,7 +113,8 @@ func (h *handlers) tuningSnapshot() tuningSnapshot {
return out
}
// handleGetRecommendationTuning implements GET /api/admin/recommendation-tuning.
// handleGetRecommendationTuning implements GET
// /api/admin/recommendation-tuning.
func (h *handlers) handleGetRecommendationTuning(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, h.tuningSnapshot())
}
+6 -5
View File
@@ -90,15 +90,15 @@ func requesterNames(ctx context.Context, q *dbq.Queries, rows []dbq.Request) map
return names
}
// approveRequestBody is the optional JSON body for POST /api/admin/requests/:id/approve.
// profile_id picks the release-type profile an artist request is followed
// on; omitted, the default profile.
// approveRequestBody is the optional JSON body for POST
// /api/admin/requests/:id/approve. profile_id picks the release-type profile an
// artist request is followed on; omitted, the default profile.
type approveRequestBody struct {
ProfileID string `json:"profile_id"`
}
// handleApproveRequest implements POST /api/admin/requests/:id/approve. The
// request goes on the wanted list (M502 #5520): an album or track request
// request goes on the wanted list: an album or track request
// wants its album, an artist request follows the artist.
func (h *handlers) handleApproveRequest(w http.ResponseWriter, r *http.Request) {
admin, ok := auth.UserFromContext(r.Context())
@@ -147,7 +147,8 @@ func (h *handlers) handleApproveRequest(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, requestViewFrom(row))
}
// rejectRequestBody is the optional JSON body for POST /api/admin/requests/:id/reject.
// rejectRequestBody is the optional JSON body for POST
// /api/admin/requests/:id/reject.
type rejectRequestBody struct {
Notes string `json:"notes"`
}
+5 -4
View File
@@ -55,8 +55,9 @@ func seedPendingArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMB
return createArtistRequest(t, h, user, artistMBID, artistName)
}
// TestHandleListAdminRequests_DefaultStatus_Pending seeds 2 pending + 1 rejected
// and verifies that GET without ?status returns only the 2 pending rows.
// TestHandleListAdminRequests_DefaultStatus_Pending seeds 2 pending + 1
// rejected and verifies that GET without ?status returns only the 2 pending
// rows.
func TestHandleListAdminRequests_DefaultStatus_Pending(t *testing.T) {
h, _ := testHandlers(t)
resetRequests(t, h)
@@ -96,8 +97,8 @@ func TestHandleListAdminRequests_DefaultStatus_Pending(t *testing.T) {
}
}
// TestHandleListAdminRequests_StatusFilter verifies GET ?status=rejected returns
// only rejected rows.
// TestHandleListAdminRequests_StatusFilter verifies GET ?status=rejected
// returns only rejected rows.
func TestHandleListAdminRequests_StatusFilter(t *testing.T) {
h, _ := testHandlers(t)
resetRequests(t, h)
+2 -2
View File
@@ -76,8 +76,8 @@ type scanTriggerResp struct {
// handleTriggerScan implements POST /api/admin/scan/run.
// Returns 202 on success, 409 if a scan is already in flight (and not stale),
// 500 on DB errors. The lazy reaper inside library.TryStartScan handles
// stale (>1h) in-flight rows automatically — the operator no longer needs
// to manually clear stuck rows after a server crash.
// stale (>1h) in-flight rows automatically, so a row left stuck by a
// server crash needs no manual clearing.
func (h *handlers) handleTriggerScan(w http.ResponseWriter, _ *http.Request) {
bgCtx := context.Background()
started, existing, err := library.TryStartScan(
+4 -4
View File
@@ -18,7 +18,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
// Searching for wants and grabbing releases (M502 #5521): the schedule's
// Searching for wants and grabbing releases: the schedule's
// settings, each want's candidates, and a person's Search, Grab and "never
// this one".
@@ -33,17 +33,17 @@ type searchSettingsView struct {
Tiers []release.TierInfo `json:"tiers"`
LastPassAt *string `json:"last_pass_at"`
LastPass json.RawMessage `json:"last_pass"`
// ImportRoot is the library folder new music is filed into (#5522); ""
// ImportRoot is the library folder new music is filed into; ""
// is the first of ScanPaths, the library's folders.
ImportRoot string `json:"import_root"`
ScanPaths []string `json:"scan_paths"`
// The download lifecycle (#5523): seed an imported download to this
// The download lifecycle: seed an imported download to this
// ratio or for this many days; give up on one nobody shares after this
// many hours. 0 is: remove once imported, no time limit, never.
SeedRatio float32 `json:"seed_ratio"`
SeedDays int32 `json:"seed_days"`
StallHours int32 `json:"stall_hours"`
// UpgradeUntil is the tier a held album is upgraded to (#5524); "" is
// UpgradeUntil is the tier a held album is upgraded to; "" is
// no quality upgrades.
UpgradeUntil string `json:"upgrade_until"`
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
)
// The admin Overview's status sections (M504): after the verdict, who is
// The admin Overview's status sections: after the verdict, who is
// listening, what is downloading, what the library holds and how much room
// it has, and how the server itself is doing. One read for the page, polled
// while it is open.
+1 -1
View File
@@ -31,7 +31,7 @@ func openPlay(t *testing.T, h *handlers, user, track pgtype.UUID, ago string) {
}
}
// The Overview's status (M504): a play still inside its track's length is
// The Overview's status: a play still inside its track's length is
// listening now; one long past it, never closed by its client, is not.
func TestAdminStatus_ListeningAndServer(t *testing.T) {
h, pool := testHandlers(t)
+3 -3
View File
@@ -15,9 +15,9 @@ type adminQueueView struct {
Since *string `json:"since"`
}
// adminSummaryView is what the admin rail and the Overview's verdict read
// (#5605): every queue an admin acts on, in one response, so each admin page
// pays one cheap read for its rail.
// adminSummaryView is what the admin rail and the Overview's verdict read:
// every queue an admin acts on, in one response, so each admin page pays one
// cheap read for its rail.
type adminSummaryView struct {
Requests adminQueueView `json:"requests"`
Reports adminQueueView `json:"reports"`
+2 -3
View File
@@ -32,7 +32,7 @@ func getAdminSummary(t *testing.T, h *handlers, user dbq.User) (int, adminSummar
}
// The summary counts each queue an admin acts on and says since when the
// oldest item has waited; an empty queue has no since (#5605).
// oldest item has waited; an empty queue has no since.
func TestAdminSummary_CountsTheQueuesAndSinceWhen(t *testing.T) {
h, _ := testHandlers(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
@@ -64,8 +64,7 @@ func TestAdminSummary_CountsTheQueuesAndSinceWhen(t *testing.T) {
}
// Duplicates count what needs review: a cross-release group is kept and
// linked by the resolver and waits on nobody (operator, 2026-10-19: the
// headline said 3,479 when 536 needed review).
// linked by the resolver and waits on nobody.
func TestAdminSummary_DuplicatesCountOnlyWhatNeedsReview(t *testing.T) {
h, _ := testHandlers(t)
truncateLibrary(t, h.pool)
+6 -6
View File
@@ -12,7 +12,7 @@ import (
)
// suspectTrackView is one flagged track. FilePath is the evidence: the
// markers are read from its basename, and the operator needs to see it to
// markers are read from its basename, and an admin needs to see it to
// judge whether the flag is right.
type suspectTrackView struct {
TrackID string `json:"track_id"`
@@ -27,11 +27,11 @@ type suspectTrackView struct {
TrackNumber *int32 `json:"track_number"`
Markers []string `json:"markers"`
// Verdict is "suspect" while the track is held back from radio and the
// mixes, "fine" once the operator let it back, null before the resolver's
// first pass (M498 #5439). VerdictAt is when it was last set.
// mixes, "fine" once an admin let it back, null before the resolver's
// first pass. VerdictAt is when it was last set.
Verdict *string `json:"verdict"`
VerdictAt *string `json:"verdict_at"`
// Replacing: the album is on the wanted list for a clean copy (#5524);
// Replacing: the album is on the wanted list for a clean copy;
// SearchedAt is when one was last searched for, null before the first.
Replacing bool `json:"replacing"`
SearchedAt *string `json:"searched_at"`
@@ -57,8 +57,8 @@ type adminSuspectSourcesResponse struct {
//
// A marker is a reason to look, not proof: a band can title a song "Music
// Video". The duplicate resolver holds a flagged track back from radio and the
// mixes and merges it away where a clean copy exists (M498 #5439); this report
// is where the operator sees that and can say a track is fine.
// mixes and merges it away where a clean copy exists; this report
// is where an admin sees that and can say a track is fine.
func (h *handlers) handleListSuspectSources(w http.ResponseWriter, r *http.Request) {
limit, offset, err := parsePaging(r.URL.Query())
if err != nil {
+3 -2
View File
@@ -15,7 +15,7 @@ import (
// tagSourceResp is the wire shape for one tag-enrichment provider in the
// GET /api/admin/tag-sources list. Mirrors the cover-sources admin surface
// (admin_cover_sources.go) — a separate, independent settings card so a new
// tag source is added without touching art settings (#1490).
// tag source is added without touching art settings.
type tagSourceResp struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
@@ -114,7 +114,8 @@ type testTagSourceResp struct {
Error string `json:"error,omitempty"`
}
// handleTestTagSource implements POST /api/admin/tag-sources/{provider_id}/test.
// handleTestTagSource implements POST
// /api/admin/tag-sources/{provider_id}/test.
func (h *handlers) handleTestTagSource(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "provider_id")
+4 -4
View File
@@ -10,7 +10,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
)
// removeTrackResponse is the wire shape from spec §5 (M7 #372). The two
// removeTrackResponse is DELETE /api/admin/tracks/{id}'s answer. The two
// optional fields are omitted unless the corresponding cleanup actually
// happened, so the response stays minimal in the common case.
type removeTrackResponse struct {
@@ -21,9 +21,9 @@ type removeTrackResponse struct {
// handleRemoveTrack implements DELETE /api/admin/tracks/{id}.
//
// Admin-only (gated by auth.RequireAdmin on the /admin route group). Deletes the
// file, then the DB row, and runs the album/artist cascade tidy-up — and deletes
// nothing at all when the file cannot be removed (#3918).
// Admin-only (gated by auth.RequireAdmin on the /admin route group). Deletes
// the file, then the DB row, and runs the album/artist cascade tidy-up — and
// deletes nothing at all when the file cannot be removed.
//
// The codes this handler emits are not_found,
// library_not_writable (409) and file_delete_failed when the file could not be
+2 -4
View File
@@ -107,10 +107,8 @@ func seedTrackForRemoveTest(t *testing.T, h *handlers, unique, trackMbid, albumM
}
// TestHandleRemoveTrack_NonAdmin403 verifies the RequireAdmin middleware
// rejects non-admin callers. Asserts the existing middleware shape
// ({"error":"not_authorized"}) — spec §5 says `unauthorized`, but the
// shared middleware emits not_authorized today, and this handler doesn't
// own that envelope.
// rejects non-admin callers, with the shared middleware's
// {"error":"not_authorized"} envelope, which this handler doesn't own.
func TestHandleRemoveTrack_NonAdmin403(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
+28 -27
View File
@@ -19,7 +19,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
// The wanted list's admin surface (M502 #5520): wants, follows and
// The wanted list's admin surface: wants, follows and
// release-type profiles.
// wantView is one row of GET /api/admin/wanted.
@@ -40,10 +40,10 @@ type wantView struct {
HeldAt *string `json:"held_at"`
ImageURL string `json:"image_url"`
// Upgrade: why a held album is wanted again, quality or rip, and
// HeldQuality what the library's copy is (#5524).
// HeldQuality what the library's copy is.
Upgrade string `json:"upgrade,omitempty"`
HeldQuality string `json:"held_quality,omitempty"`
// Search is the schedule's last look; Grab the download running (#5521).
// Search is the schedule's last look; Grab the download running.
Search *wantSearchView `json:"search"`
Grab *wantGrabView `json:"grab"`
}
@@ -58,7 +58,7 @@ type wantSearchView struct {
}
// wantGrabView is the want's latest download, running or ended, and what
// its import made of it (#5522).
// its import made of it.
type wantGrabView struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -76,19 +76,19 @@ type wantGrabView struct {
}
// wantCounts sizes each tab of the Wanted page. Artists: how many artists
// the list holds albums of, its main page (#5572).
// the list holds albums of, its main page.
type wantCounts struct {
Artists int64 `json:"artists"`
// Unmatched: library albums waiting to be matched by hand (#5579).
// Unmatched: library albums waiting to be matched by hand.
Unmatched int64 `json:"unmatched"`
Wanted int64 `json:"wanted"`
Missing int64 `json:"missing"`
Held int64 `json:"held"`
// Upgrades: held albums wanted for a better copy (#5524).
// Upgrades: held albums wanted for a better copy.
Upgrades int64 `json:"upgrades"`
Following int64 `json:"following"`
// Activity: downloads running; Attention: of those, the ones waiting on
// a person; Blocked: releases set aside (#5522).
// a person; Blocked: releases set aside.
Activity int64 `json:"activity"`
Attention int64 `json:"attention"`
Blocked int64 `json:"blocked"`
@@ -100,13 +100,13 @@ type wantsPage struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
Counts wantCounts `json:"counts"`
// Library is the one-time listing of the library's albums (#5567).
// Library is the one-time listing of the library's albums.
Library libraryListingView `json:"library"`
// Artist heads one artist's page, when the list is narrowed to them.
Artist *wantArtistView `json:"artist,omitempty"`
}
// wantArtistView is one artist on the Wanted view's main page (#5572): how
// wantArtistView is one artist on the Wanted view's main page: how
// many of their albums the list holds, of how many.
type wantArtistView struct {
ArtistMBID string `json:"artist_mbid"`
@@ -132,16 +132,15 @@ type wantArtistsPage struct {
}
// libraryListingView: whether the library's albums are all on the list
// yet, and how many could not be matched to MusicBrainz (#5569); those are
// listed in the Collection's Unmatched section (#5579).
// yet, and how many could not be matched to MusicBrainz; those are
// listed in the Collection's Unmatched section.
type libraryListingView struct {
ListedAt *string `json:"listed_at"`
Unlisted int32 `json:"unlisted"`
}
// unmatchedAlbumView is one album in the Collection's Unmatched section
// (#5579): why no MusicBrainz album matched it, or that a person left it
// off.
// unmatchedAlbumView is one album in the Collection's Unmatched section: why
// no MusicBrainz album matched it, or that a person left it off.
type unmatchedAlbumView struct {
AlbumID string `json:"album_id"`
Title string `json:"title"`
@@ -200,7 +199,7 @@ func wantArtistViewOf(row dbq.ListWantArtistsRow) wantArtistView {
}
// handleListWantArtists implements GET /api/admin/wanted/artists?q=&offset=:
// the Wanted view's main page, every artist on the list A–Z (#5572).
// the Wanted view's main page, every artist on the list A–Z.
func (h *handlers) handleListWantArtists(w http.ResponseWriter, r *http.Request) {
offset := offsetParam(r)
q := dbq.New(h.pool)
@@ -224,12 +223,12 @@ func (h *handlers) handleListWantArtists(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, out)
}
// handleListWants implements
// GET /api/admin/wanted?state=wanted|missing|upgrade|held|all&artist=&q=&offset=.
// wanted is everything open but missing files; upgrade the held albums
// wanted for a better copy (#5524). Each list runs artist by
// artist, A–Z, like the library (#5572); q narrows it to an artist or
// album, and artist to one artist's page, which it then heads.
// handleListWants implements GET
// /api/admin/wanted?state=wanted|missing|upgrade|held|all&artist=&q=&offset=.
// wanted is everything open but missing files; upgrade the held albums wanted
// for a better copy. Each list runs artist by artist, A–Z, like the library; q
// narrows it to an artist or album, and artist to one artist's page, which it
// then heads.
func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
state := r.URL.Query().Get("state")
if state == "" {
@@ -329,7 +328,7 @@ func wantViewOf(row dbq.ListWantsRow) wantView {
return v
}
// wantTracksView is an album's track list for the Wanted view (#5572): the
// wantTracksView is an album's track list for the Wanted view: the
// library's own tracks for an album it holds or held, else MusicBrainz's
// standard edition of what is wanted.
type wantTracksView struct {
@@ -687,7 +686,8 @@ func (h *handlers) handleUpdateReleaseProfile(w http.ResponseWriter, r *http.Req
})
}
// handleDeleteReleaseProfile implements DELETE /api/admin/release-profiles/{id}.
// handleDeleteReleaseProfile implements DELETE
// /api/admin/release-profiles/{id}.
func (h *handlers) handleDeleteReleaseProfile(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
@@ -726,7 +726,7 @@ func deref(s *string) string {
}
// handleListUnmatched implements GET /api/admin/wanted/unmatched: the
// Collection's Unmatched section (#5579), the albums no MusicBrainz album
// Collection's Unmatched section, the albums no MusicBrainz album
// matched and, after them, the ones a person left off.
func (h *handlers) handleListUnmatched(w http.ResponseWriter, r *http.Request) {
rows, err := dbq.New(h.pool).ListUnmatchedLibraryAlbums(r.Context(), unmatchedShown)
@@ -793,8 +793,9 @@ func (h *handlers) handleMatchUnmatched(w http.ResponseWriter, r *http.Request)
Agree: m.Agree, Tracks: m.Tracks})
}
// handleLeaveUnmatchedOff implements POST /api/admin/wanted/unmatched/{id}/leave-off,
// and handlePutUnmatchedBack its undoing, POST …/{id}/put-back.
// handleLeaveUnmatchedOff implements POST
// /api/admin/wanted/unmatched/{id}/leave-off, and handlePutUnmatchedBack its
// undoing, POST …/{id}/put-back.
func (h *handlers) handleLeaveUnmatchedOff(w http.ResponseWriter, r *http.Request) {
h.setUnmatched(w, r, h.wanted.LeaveAlbumOff)
}
+3 -3
View File
@@ -67,7 +67,7 @@ func listWanted(t *testing.T, h *handlers, user dbq.User, q string) wantsPage {
return page
}
// The list runs like the library (#5572): artists A–Z by the library's sort
// The list runs like the library: artists A–Z by the library's sort
// name where the library holds the artist, each artist's albums oldest
// first; q narrows it to an artist or an album.
func TestListWants_ArtistsAToZThenTheirAlbumsByDate(t *testing.T) {
@@ -109,7 +109,7 @@ func TestListWants_ArtistsAToZThenTheirAlbumsByDate(t *testing.T) {
}
}
// The Collection's catalogue (#5572): every artist on the list A–Z, each
// The Collection's catalogue: every artist on the list A–Z, each
// with how many of their albums are held; an artist's page lists every album
// of theirs, held or not, under that artist.
func TestListWantArtists_CatalogueAndAnArtistsPage(t *testing.T) {
@@ -292,7 +292,7 @@ func TestWantTracks_HeldAlbumShowsTheLibrarysTracks(t *testing.T) {
}
}
// The Unmatched section (#5579): the albums matching could not place, a
// The Unmatched section: the albums matching could not place, a
// local band's album left off and put back, and a link that names no
// MusicBrainz album refused before MusicBrainz is asked.
func TestUnmatched_ListLeaveOffPutBack(t *testing.T) {
+47 -52
View File
@@ -1,5 +1,5 @@
// Package api implements Minstrel's native JSON surface under /api. It is
// consumed by the built-in web SPA and (eventually) the Flutter client.
// consumed by the built-in web SPA and the Android app.
// Subsonic-compatible endpoints under /rest are intentionally separate —
// see internal/subsonic — and the two packages must not depend on each other.
package api
@@ -105,15 +105,13 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
// query token (UPnP / Sonos path; see streamAuthOk). The
// middleware attaches user to context when a valid cookie /
// bearer is present but does NOT 401 on absence; the handler's
// own streamAuthOk performs the actual auth check. See the
// design at
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
// own streamAuthOk performs the actual auth check.
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream", h.handleGetStream)
// Extension-bearing alias so Sonos's URL probe can identify the
// audio format from the path. The {ext} param is consumed by chi
// and ignored by the handler (which keys off {id}). See task #610.
// and ignored by the handler (which keys off {id}).
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream)
// The leveled stream for Sonos/UPnP (M464 #5001): session or a
// The leveled stream for Sonos/UPnP: session or a
// leveled token, like the plain stream.
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/leveled.flac", h.handleGetLeveledStream)
@@ -154,7 +152,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Get("/albums/{id}/cover", h.handleGetCover)
authed.Get("/library/shuffle", h.handleLibraryShuffle)
authed.Get("/library/albums", h.handleListLibraryAlbums)
// Browse indexes (#367). Genre filtering rides
// Browse indexes. Genre filtering rides
// /library/albums?genre= rather than a path segment, because raw
// ID3 genres contain slashes ("Rock/Pop") that a path can't carry.
authed.Get("/library/genres", h.handleListGenres)
@@ -169,8 +167,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Get("/search", h.handleSearch)
authed.Get("/radio", h.handleRadio)
authed.Get("/discover/suggestions", h.handleListSuggestions)
// Snooze = "not right now", time-boxed and self-expiring
// (#2374). Not a dislike — see the migration for why.
// Snooze = "not right now", time-boxed and self-expiring.
// Not a dislike — see the migration for why.
authed.Post("/discover/suggestions/{mbid}/snooze", h.handleSnoozeSuggestion)
authed.Delete("/discover/suggestions/{mbid}/snooze", h.handleUnsnoozeSuggestion)
authed.Get("/discover/snoozes", h.handleListSuggestionSnoozes)
@@ -178,10 +176,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Get("/home/index", h.handleGetHomeIndex)
authed.Post("/events", h.handleEvents)
authed.Get("/events/stream", h.handleEventsStream)
// UPnP / Sonos cast slice: issue a short-lived HMAC stream URL
// the speaker can fetch without the user's session. See the
// design at
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
// UPnP / Sonos casting: issue a short-lived HMAC stream URL the
// speaker can fetch without the user's session.
authed.Post("/cast/stream-token", h.handleCastStreamToken)
authed.Post("/likes/tracks/{id}", h.handleLikeTrack)
authed.Delete("/likes/tracks/{id}", h.handleUnlikeTrack)
@@ -194,7 +190,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Get("/likes/artists", h.handleListLikedArtists)
authed.Get("/likes/ids", h.handleGetLikedIDs)
// The release catalogue (M502 #5519): request search asks
// The release catalogue: request search asks
// MusicBrainz, and art for what isn't held comes through
// Minstrel from the enabled cover-art sources.
authed.Get("/catalogue/search", h.handleCatalogueSearch)
@@ -213,13 +209,13 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
// load failures). Admin-only inbox; any user can report.
authed.Post("/playback-errors", h.handleReportPlaybackError)
// Device diagnostics ingest (M9). Any signed-in user can
// Device diagnostics ingest. Any signed-in user can
// POST a batch, but events are only stored when the
// account's debug_mode_enabled flag is on (handler no-ops
// otherwise). Admin views live under /admin/diagnostics.
authed.Post("/diagnostics", h.handleReportDiagnostics)
// Self-hosted in-app update channel (#397). Auth-gated to
// Self-hosted in-app update channel. Auth-gated to
// prevent anonymous bandwidth abuse on the APK stream;
// /apk additionally per-user rate-limited.
authed.Get("/client/version", h.handleClientVersion)
@@ -236,14 +232,15 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Delete("/acquisition/services/{code}/api-key", h.handleSetAcquisitionSecret("api_key"))
admin.Put("/acquisition/services/{code}/password", h.handleSetAcquisitionSecret("password"))
admin.Delete("/acquisition/services/{code}/password", h.handleSetAcquisitionSecret("password"))
// The queues that wait on an admin, for the rail and the Overview (#5605).
// The queues that wait on an admin, for the rail and the
// Overview.
admin.Get("/summary", h.handleGetAdminSummary)
admin.Get("/status", h.handleGetAdminStatus)
admin.Get("/requests", h.handleListAdminRequests)
admin.Post("/requests/{id}/approve", h.handleApproveRequest)
admin.Post("/requests/{id}/reject", h.handleRejectRequest)
// The wanted list (M502 #5520): what Minstrel is looking
// The wanted list: what Minstrel is looking
// for, the artists it follows, and the release-type
// profiles that decide which of their releases count.
admin.Get("/wanted", h.handleListWants)
@@ -253,7 +250,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Post("/wanted/unmatched/{id}/leave-off", h.handleLeaveUnmatchedOff)
admin.Post("/wanted/unmatched/{id}/put-back", h.handlePutUnmatchedBack)
admin.Delete("/wanted/{id}", h.handleRemoveWant)
// Searching and grabbing (M502 #5521).
// Searching and grabbing.
admin.Get("/acquisition/settings", h.handleGetSearchSettings)
admin.Put("/acquisition/settings", h.handlePutSearchSettings)
admin.Get("/wanted/{id}/candidates", h.handleListCandidates)
@@ -294,12 +291,12 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Put("/network-settings", h.handleUpdateNetworkSettings)
// Policy for wanting back an album whose files went missing
// (#290). Sits beside the missing-files list it governs,
// because the operator meets it on that surface.
// Sits beside the missing-files list it governs, because an
// admin meets it on that surface.
admin.Get("/library/reacquisition", h.handleGetReacquisitionSettings)
admin.Put("/library/reacquisition", h.handleUpdateReacquisitionSettings)
// Where the library lives (#6129). A changed folder moves its
// Where the library lives. A changed folder moves its
// tracks rather than re-adding them.
admin.Get("/library/folders", h.handleGetLibraryFolders)
admin.Put("/library/folders", h.handlePutLibraryFolders)
@@ -309,7 +306,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
// Sits under /library rather than /tracks because what it
// reports is a property of the library's relationship to disk,
// and because the destructive /tracks/{id} route above must
// not be mistaken for it (#2527).
// not be mistaken for it.
admin.Get("/library/missing", h.handleListMissingTracks)
admin.Get("/library/suspect-sources", h.handleListSuspectSources)
admin.Put("/library/suspect-sources/{id}", h.handleSetSuspectSourceVerdict)
@@ -325,9 +322,10 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Put("/library/acoustid-settings", h.handleUpdateAcoustIDSettings)
admin.Post("/library/acoustid/run", h.handleRunAcoustIDLookup)
admin.Get("/library/acoustid/unsettled", h.handleListUnsettledAcoustID)
// Duplicates report (#3912): proposals from the duplicate sweep, a
// trigger to sweep now, dismissal, and the merge (#3911), which deletes
// the removed copies' files after moving their history onto the kept one.
// Duplicates report: proposals from the duplicate sweep, a
// trigger to sweep now, dismissal, and the merge, which deletes
// the removed copies' files after moving their history onto the
// kept one.
admin.Get("/library/duplicates", h.handleListDuplicates)
admin.Get("/library/duplicates/resolved", h.handleListResolvedDuplicates)
admin.Post("/library/duplicates/sweep", h.handleRunDuplicateSweep)
@@ -346,7 +344,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Put("/users/{id}/auto-approve", h.handleAdminAutoApproveToggle)
admin.Put("/users/{id}/debug-mode", h.handleAdminDebugModeToggle)
// Device diagnostics timeline + device overview (M9).
// Device diagnostics timeline + device overview.
admin.Get("/diagnostics", h.handleListAdminDiagnostics)
admin.Get("/diagnostics/devices", h.handleListAdminDiagnosticDevices)
@@ -366,12 +364,12 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Get("/notification-email", h.handleGetNotificationEmail)
admin.Put("/notification-email", h.handleUpdateNotificationEmail)
// Recommendation tuning lab (#1250): scoring-weight
// Recommendation tuning: scoring-weight
// profiles + taste-build knobs, DB-backed, live effect.
admin.Get("/recommendation-tuning", h.handleGetRecommendationTuning)
admin.Patch("/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning)
admin.Post("/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning)
// Weekly outcome trends + knob-turn markers (#1251).
// Weekly outcome trends + knob-turn markers.
admin.Get("/recommendation-trends", h.handleGetRecommendationTrends)
})
@@ -398,7 +396,7 @@ type handlers struct {
recSettings *recsettings.Service
rng func() float64
// librarySize memoises the track count that sizes the candidate pool
// (#3880). Held here rather than counted per request: the count is a
// Held here rather than counted per request: the count is a
// full table scan, and library size only moves when a scan runs.
librarySize *recommendation.LibrarySize
requests *requests.Service
@@ -413,47 +411,47 @@ type handlers struct {
dataDir string
mailer mailer.Sender
eventbus *eventbus.Bus
// notifier writes the notifications inbox (M489). Nil-safe: a nil
// notifier writes the notifications inbox. Nil-safe: a nil
// notifier records nothing, which is what most handler tests want.
notifier *notifications.Notifier
playlistScheduler *playlists.Scheduler
// reacqSettings is the DB-backed policy for auto re-acquisition of
// missing files (milestone #290) — grace window, backoff, attempt caps.
// missing files — grace window, backoff, attempt caps.
// Cached in the service, so the admin card reads it without a query.
reacqSettings *reacquisition.SettingsService
// fingerprintSettings is the fingerprinting policy (M400 #3913), the same
// fingerprintSettings is the fingerprinting policy, the same
// instance the scanner and the fingerprint workers read, so a save from the
// admin card reaches them without a restart. Nil serves the defaults.
fingerprintSettings *library.FingerprintSettingsService
// loudnessSettings is the loudness analysis policy (M464 #4995), the same
// loudnessSettings is the loudness analysis policy, the same
// instance the loudness backfill reads. Nil serves the defaults.
loudnessSettings *library.LoudnessSettingsService
// acoustIDLookup is the AcoustID lookup worker (M401): its settings, its
// acoustIDLookup is the AcoustID lookup worker: its settings, its
// status and "look up now". Nil serves the defaults (off).
acoustIDLookup *library.AcoustIDLookupWorker
// acquisition is the operator's indexers and download clients (M502).
// acquisition is the configured indexers and download clients.
acquisition *acquire.Store
// catalogue is the release catalogue read from MusicBrainz, and
// catalogueArt the images for what isn't held (M502 #5519).
// catalogueArt the images for what isn't held.
catalogue *catalogue.Service
catalogueArt *catalogue.Art
// wanted is the wanted list: follows, release-type profiles and wants
// (M502 #5520). Its worker runs in main; the API reads and edits.
// wanted is the wanted list: follows, release-type profiles and wants.
// Its worker runs in main; the API reads and edits.
wanted *wanted.Service
// searcher searches for wants and grabs releases for them (#5521).
// searcher searches for wants and grabs releases for them.
searcher *wanted.Searcher
// importer refuses a download at a person's word (#5522). The import
// importer refuses a download at a person's word. The import
// loop itself runs in main; this one only acts on a request.
importer *importer.Importer
// lifecycle gives up on a download at a person's word (#5523); its loop
// lifecycle gives up on a download at a person's word; its loop
// runs in main.
lifecycle *lifecycle.Service
// catalogueSearches reuses recent MusicBrainz answers; nil asks every
// time, which is what handler tests get.
catalogueSearches *searchCache
// leveled renders the leveled streams handed to Sonos/UPnP speakers
// (M464 #5001). Nil when its cache directory cannot be made: a level
// request then gets the plain stream.
// leveled renders the leveled streams handed to Sonos/UPnP speakers. Nil
// when its cache directory cannot be made: a level request then gets the
// plain stream.
leveled *library.LeveledRenderer
// setupToken must accompany the first registration while no users exist
// (see auth.SetupToken). requireSetupToken is set by Mount, the only
@@ -473,13 +471,10 @@ type handlers struct {
// middleware on every request and edited from the admin network card.
netSettings *netsettings.Service
// streamSecret is the HMAC key used by SignStreamToken /
// VerifyStreamToken to authenticate the UPnP-speaker stream path
// (see internal/api/stream_token.go and the design at
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md).
// nil in slice 1; slice 2 wires the env-var-with-app_preferences-
// fallback loader. A nil secret leaves the cookie path intact and
// makes the token path unreachable (HMAC of empty key won't match
// anything a client mints), which is the desired slice-1 default.
// VerifyStreamToken to authenticate the UPnP-speaker stream path (see
// internal/api/stream_token.go), from config.Config.StreamSecret. A nil
// secret leaves the cookie path intact and makes the token path
// unreachable (HMAC of empty key won't match anything a client mints).
streamSecret []byte
}
+1 -1
View File
@@ -140,7 +140,7 @@ func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
Value: token,
Path: "/",
HttpOnly: true,
Secure: auth.IsHTTPS(r, h.netSettings.Hops()), // plain HTTP and LAN stay functional (rule 94)
Secure: auth.IsHTTPS(r, h.netSettings.Hops()), // plain HTTP and LAN stay functional
SameSite: http.SameSiteStrictMode,
MaxAge: int(sessionCookieMaxAge.Seconds()),
})
+4 -4
View File
@@ -130,15 +130,15 @@ func (h *handlers) sendResetEmail(ctx context.Context, user dbq.User) error {
return h.mailer.Send(ctx, *user.Email, mailer.ResetEmailSubject, textBody, htmlBody)
}
// errNoPublicURL stops a reset email from going out before the operator has
// errNoPublicURL stops a reset email from going out before an admin has
// said where Minstrel lives. It surfaces in the log, not the response, which
// stays the same opaque 200 either way.
var errNoPublicURL = errors.New("forgot-password: no public URL set (Admin › Email & network › Public address); reset email not sent")
// buildResetURL builds the emailed link from the operator-set public URL.
// buildResetURL builds the emailed link from an admin-set public URL.
// It deliberately ignores the request: the Host header is whatever the
// requester sent, and building from it let a forged Host plant a real reset
// token on a link to someone else's server.
// requester sent, and building from it would let a forged Host plant a real
// reset token on a link to someone else's server.
func buildResetURL(publicURL, token string) (string, error) {
if publicURL == "" {
return "", errNoPublicURL
+3 -2
View File
@@ -175,7 +175,8 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
return
}
// Atomic invite redeem (if applicable). Now we have user.ID for redeemed_by.
// Atomic invite redeem (if applicable). Now we have user.ID for
// redeemed_by.
if usedInviteToken != "" {
rows, rerr := q.RedeemInvite(r.Context(), dbq.RedeemInviteParams{
Token: usedInviteToken,
@@ -214,7 +215,7 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
Value: sessionToken,
Path: "/",
HttpOnly: true,
Secure: auth.IsHTTPS(r, h.netSettings.Hops()), // plain HTTP and LAN stay functional (rule 94)
Secure: auth.IsHTTPS(r, h.netSettings.Hops()), // plain HTTP and LAN stay functional
SameSite: http.SameSiteStrictMode,
MaxAge: int(sessionCookieMaxAge.Seconds()),
})
+2 -1
View File
@@ -158,7 +158,8 @@ func TestRegister_OpenMode_NoTokenNeeded(t *testing.T) {
h, pool := testHandlers(t)
seedUser(t, pool, "preexisting", "pw", true)
// Switch to open mode; restore invite_only afterwards so other tests are
// not affected (registration_settings is a singleton not truncated by ResetDB).
// not affected (registration_settings is a singleton not truncated by
// ResetDB).
if _, err := pool.Exec(context.Background(),
"UPDATE registration_settings SET mode = 'open' WHERE id = true"); err != nil {
t.Fatalf("set mode: %v", err)
+1 -1
View File
@@ -77,7 +77,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
// seedUser creates a test user. The supplied username is automatically
// prefixed with dbtest.TestUserPrefix so dbtest.ResetDB cleans it up
// without touching the operator's admin row. Callers that need to send
// without touching the developer's admin row. Callers that need to send
// the username in HTTP bodies or compare against API responses must
// include the same prefix in their literals.
func seedUser(t *testing.T, pool *pgxpool.Pool, username, password string, isAdmin bool) dbq.User {
+2 -5
View File
@@ -20,7 +20,7 @@ const (
type castTokenRequest struct {
TrackID string `json:"trackId"`
ExpSeconds int `json:"expSeconds,omitempty"`
// Level asks for the leveled stream (M464 #5001): the track rendered at
// Level asks for the leveled stream: the track rendered at
// the user's loudness gain. AsAlbum says the track is playing as part of
// its album in order, which picks album gain in auto mode; only the
// client holding the queue knows it.
@@ -68,7 +68,7 @@ func mimeForFormat(format string) string {
// extension (Content-Type header alone is insufficient) -- without a
// recognizable extension, Sonos reports TrackDuration=0 and seeks
// trigger auto-advance because every position past 0 looks past-the-
// end. Defaults to "mp3" for unknown formats. See task #610.
// end. Defaults to "mp3" for unknown formats.
func extForFormat(format string) string {
switch strings.ToLower(strings.TrimSpace(format)) {
case "mp3", "mpeg":
@@ -101,9 +101,6 @@ func extForFormat(format string) string {
//
// expSeconds is clamped to [60, 86400]; default 21600 (6h) — long enough
// to play through any typical track without re-minting mid-playback.
//
// Part of the output-picker UPnP slice. See
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
+1 -1
View File
@@ -54,7 +54,7 @@ func TestCastStreamToken_HappyPath(t *testing.T) {
t.Fatalf("URL missing stream path: %s", resp.URL)
}
// Stream URL must carry a file extension so Sonos's URL probe can
// identify the audio format (see task #610). Track seeded above is
// identify the audio format. Track seeded above is
// .flac via seedTrack's default file_format.
if !strings.Contains(resp.URL, "/stream.flac?") {
t.Fatalf("URL missing file-extension segment: %s", resp.URL)
+5 -4
View File
@@ -36,9 +36,10 @@ type catalogueSearchResult struct {
Requested bool `json:"requested"`
}
// handleCatalogueSearch implements GET /api/catalogue/search?q=&kind=artist|album|track
// (M502 #5519), asking MusicBrainz directly. An unreachable MusicBrainz is a
// 503 the page shows as such (rule 164).
// handleCatalogueSearch implements GET
// /api/catalogue/search?q=&kind=artist|album|track asking MusicBrainz directly.
// An unreachable MusicBrainz is a 503 the page shows as such, not an empty
// result.
func (h *handlers) handleCatalogueSearch(w http.ResponseWriter, r *http.Request) {
kind := strings.TrimSpace(r.URL.Query().Get("kind"))
switch kind {
@@ -250,7 +251,7 @@ func (h *handlers) catalogueInLibrary(ctx context.Context, kind, mbid string) (b
// handleCatalogueArt implements GET /api/catalogue/art/{kind}/{mbid} for an
// artist or a release group Minstrel doesn't hold. The image comes from the
// operator's enabled cover-art sources and is kept on disk, so the browser
// enabled cover-art sources and is kept on disk, so the browser
// never fetches a third-party host. A 404 shows the placeholder.
func (h *handlers) handleCatalogueArt(w http.ResponseWriter, r *http.Request) {
kind, mbid := chi.URLParam(r, "kind"), chi.URLParam(r, "mbid")
+2 -1
View File
@@ -199,7 +199,8 @@ func TestCatalogueArt_ServesTheImageOrAPlaceholder404(t *testing.T) {
if w := get("/api/catalogue/art/artist/" + searchArtistMBID + "?name=Radiohead"); w.Code != http.StatusOK || w.Header().Get("Content-Type") != "image/png" {
t.Errorf("artist: %d %q", w.Code, w.Header().Get("Content-Type"))
}
// No album source is enabled: a 404, which the page shows as its placeholder.
// No album source is enabled: a 404, which the page shows as its
// placeholder.
if w := get("/api/catalogue/art/release-group/" + searchGroupMBID); w.Code != http.StatusNotFound {
t.Errorf("release group with no source: %d", w.Code)
}
+7 -10
View File
@@ -1,6 +1,6 @@
package api
// In-app update endpoints (#397). The Android APK ships bundled with
// In-app update endpoints. The Android APK ships bundled with
// the server image so the client can self-update without an external
// app store. CI sequencing bakes the APK + sidecar version file into
// /app/client/ at image build time.
@@ -13,11 +13,8 @@ package api
// download every 60s. Real install flows fire one download per
// update; anything tighter is scripted/abusive.
//
// Returns 404 gracefully when the APK isn't present (dev environments,
// pre-CI-wiring); the client treats 404 as "no update channel available."
//
// (These paragraphs said "the Flutter client" until 2026-09-10. That client
// was deleted in v2026.08.18 — the Android app is the only one now.)
// Returns 404 gracefully when the APK isn't present (a local build); the
// client treats 404 as "no update channel available."
import (
"encoding/json"
@@ -96,10 +93,10 @@ func clientAPKAllowDownload(userID string, now time.Time) time.Duration {
// installed over that?" — the name never does.
// - Channel is a SIBLING FIELD, never a suffix inside the name.
//
// JSON rather than a positional line on purpose. The obvious growth path for
// the old one-value file was "<name> <code>", which a first-space split
// silently mangles the moment a third field appears: the code stops parsing,
// and the reader falls back to name comparison WITHOUT erroring.
// JSON rather than a positional line on purpose: a "<name> <code>" line split
// on the first space silently mangles the moment a third field appears — the
// code stops parsing, and the reader falls back to name comparison WITHOUT
// erroring.
type clientVersionSidecar struct {
Name string `json:"name"`
// Pointer, not int64: absent must stay distinguishable from zero. An
+4 -4
View File
@@ -55,9 +55,9 @@ func yearFromDate(d pgtype.Date) int {
return d.Time.Year()
}
// durationMsToSec converts millisecond durations (as stored in tracks.duration_ms)
// to seconds, rounding to nearest. Callers display seconds; sub-second precision
// is not useful in UI.
// durationMsToSec converts millisecond durations (as stored in
// tracks.duration_ms) to seconds, rounding to nearest. Callers display seconds;
// sub-second precision is not useful in UI.
func durationMsToSec(ms int32) int {
return int((ms + 500) / 1000)
}
@@ -79,7 +79,7 @@ func streamURL(trackID pgtype.UUID) string {
// cast tokens. Sonos's URL probe gates duration detection on a recognizable
// audio file extension; the bare `/stream` shape reports TrackDuration=0
// and breaks seek/auto-advance. The bare /stream route stays mounted as an
// alias for legacy / web / Subsonic clients. See task #610.
// alias for web and Subsonic clients.
func streamURLWithExt(trackID pgtype.UUID, ext string) string {
return streamURL(trackID) + "." + ext
}
+1 -1
View File
@@ -51,7 +51,7 @@ func writeRateLimited(w http.ResponseWriter, wait time.Duration) {
}
// writeErrWithLog logs the error at Error level and writes the response.
// Use for 500-class errors where the operator needs the cause in logs.
// Use for 500-class errors whose cause belongs in the logs.
func writeErrWithLog(w http.ResponseWriter, logger *slog.Logger, msg string, err error) {
if logger != nil {
logger.Error(msg, "err", err)
+11 -11
View File
@@ -22,16 +22,16 @@ type eventRequest struct {
At *string `json:"at"`
ClientID *string `json:"client_id"`
// Source tags which surface a play_started came from. "for_you"
// / "discover" feed the system-playlist rotation dedup (#415);
// / "discover" feed the system-playlist rotation dedup;
// absent / "" for library, user-playlist, radio, Subsonic.
Source *string `json:"source"`
// DeviceClass is the client's self-reported device class (#1551),
// DeviceClass is the client's self-reported device class,
// normalized + stored per play to condition radio on the current device.
DeviceClass *string `json:"device_class"`
}
// knownDeviceClasses whitelists the device classes the context-affinity facet
// buckets by (#1551). A client sends one; anything unrecognized normalizes to
// buckets by. A client sends one; anything unrecognized normalizes to
// "other", and empty/absent to "" (stored NULL → excluded from the device
// dimension). Kept permissive (no DB CHECK) so a new client class is one
// whitelist entry, not a migration.
@@ -66,9 +66,9 @@ const eventFutureSkew = 5 * time.Minute
// clampEventTime bounds a client-supplied event timestamp to sanity:
// no earlier than the account's creation (offline replays can
// legitimately be days old, but no play can predate the user) and no
// later than now + a small skew allowance. Unbounded client clocks
// previously let a skewed device write arbitrarily old plays, which
// poisoned Rediscover's "not played in 6 months" ordering (#1246).
// later than now + a small skew allowance. An unbounded client clock would
// let a skewed device write arbitrarily old plays, poisoning Rediscover's
// "not played in 6 months" ordering.
func clampEventTime(at, userCreatedAt, now time.Time) time.Time {
if at.Before(userCreatedAt) {
return userCreatedAt
@@ -157,11 +157,11 @@ func (h *handlers) handleEventPlayStarted(
}
// handleEventPlayOffline records a complete play that happened with
// no/flaky connectivity, replayed from the Flutter offline mutation
// queue (#426 part B). `at` is the original (client) play-start time
// — parsed by handleEvents from req.At — so history lands on the
// real timeline, not replay time. duration_played_ms drives the skip
// rule; source advances #415 rotation for system-playlist plays.
// no or flaky connectivity, replayed from a client's offline queue.
// `at` is the original (client) play-start time — parsed by handleEvents
// from req.At — so history lands on the real timeline, not replay time.
// duration_played_ms drives the skip rule; source advances the
// system-playlist rotation.
func (h *handlers) handleEventPlayOffline(
w http.ResponseWriter, r *http.Request,
user dbq.User, req eventRequest, at time.Time, clientID string,
+4 -4
View File
@@ -1,7 +1,7 @@
// Small helpers for posting live events to the SSE bus (#392). Producers
// call these from their handler success paths so the wire shape stays in
// one place. Each helper is a no-op when h.eventbus is nil (older callers
// or tests that don't construct a bus); production always has one.
// Small helpers for posting live events to the SSE bus. Producers call
// these from their handler success paths so the wire shape stays in one
// place. Each helper is a no-op when h.eventbus is nil (tests that don't
// construct a bus); production always has one.
package api
+1 -1
View File
@@ -1,6 +1,6 @@
// Live event stream — Server-Sent Events at GET /api/events/stream.
//
// Clients (today: Flutter) subscribe to be notified of state changes
// The web app and the Android app subscribe to be notified of state changes
// elsewhere in the system (likes, request status flips, quarantine
// resolutions, scan progress, playlist mutations) so static screens
// can invalidate cached data without explicit user action.
+5 -5
View File
@@ -10,17 +10,17 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
// fileRemoveAPIError answers a delete that could not reach the track's file
// (#3918). Both delete endpoints use it, so the operator gets the same
// fileRemoveAPIError answers a delete that could not reach the track's file.
// Both delete endpoints use it, so an admin gets the same
// explanation from the admin remove-track action and from quarantine's Delete
// file.
//
// The unwritable case is a 409 rather than a 500 because nothing is broken: the
// request conflicts with how the library is mounted, and the fix is the
// operator's. The message names the directory — removal writes to the parent,
// host's. The message names the directory — removal writes to the parent,
// not the file — and the uid/gid the process runs as, which is the half of a
// permission problem invisible from the host. Every case says nothing was
// deleted, because that is exactly what the operator will be worried about.
// deleted, because that is exactly what an admin will be worried about.
func fileRemoveAPIError(err error) (*apierror.Error, bool) {
var fre *library.FileRemoveError
if !errors.As(err, &fre) {
@@ -46,7 +46,7 @@ func fileRemoveAPIError(err error) (*apierror.Error, bool) {
}
// logFileRemoveFailure records a delete that could not reach its file. An
// unwritable library is an environment fact the operator can fix, so it is a
// unwritable library is an environment fact an admin can fix, so it is a
// Warn; anything else is a real fault.
func logFileRemoveFailure(logger *slog.Logger, apiErr *apierror.Error, attrs ...any) {
attrs = append(attrs, "code", apiErr.Code, "err", apiErr.Cause)
+1 -1
View File
@@ -72,7 +72,7 @@ func TestFileRemoveAPIError(t *testing.T) {
}
// The unwritable answer must name the DIRECTORY. Removal needs write access to
// the parent, so a message naming the file would send the operator to fix the
// the parent, so a message naming the file would send an admin to fix the
// wrong permissions. The directory is a prefix of the file path, which is why a
// plain "contains the directory" check could never catch that regression.
func TestFileRemoveAPIError_NotWritableNamesTheDirectoryNotTheFile(t *testing.T) {
+3 -4
View File
@@ -59,15 +59,14 @@ func (h *handlers) handleGetHome(w http.ResponseWriter, r *http.Request) {
// handleGetHomeIndex implements GET /api/home/index. Returns the same
// five sections /api/home computes, but as flat slices of entity IDs
// rather than denormalized objects. Drives the Flutter per-item
// rather than denormalized objects. Drives the Android per-item
// rendering path — the client hydrates each tile against the
// per-entity endpoints, so this response is tiny (a few KB at most)
// and the cold-visit round-trip is correspondingly short.
//
// Shares the recommendation.HomeData computation with /api/home; the
// DB cost is identical. A future optimisation could add ID-only
// query variants, but the JSON savings already shrink the wire
// payload by roughly an order of magnitude on populated libraries.
// DB cost is identical, and the wire payload is roughly an order of
// magnitude smaller on populated libraries.
func (h *handlers) handleGetHomeIndex(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
+2 -2
View File
@@ -21,7 +21,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
// The leveled stream (M464 #5001): a track rendered with the user's loudness
// The leveled stream: a track rendered with the user's loudness
// gain applied, for the Sonos and UPnP speakers that fetch their own audio.
// See internal/library/leveled.go for how it is rendered.
@@ -49,7 +49,7 @@ func VerifyLeveledStreamToken(secret []byte, trackID string, exp int64, g librar
}
// leveledStreamPath is the leveled stream's path. It ends in .flac because
// Sonos reads the format from the URL's extension (task #610).
// Sonos reads the format from the URL's extension.
func leveledStreamPath(trackID pgtype.UUID) string {
return "/api/tracks/" + uuidToString(trackID) + "/leveled.flac"
}
+3 -2
View File
@@ -119,7 +119,8 @@ func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
}
refs := make([]AlbumRef, 0, len(rows))
for _, row := range rows {
// durationSec=0: not aggregated for nested album lists per spec data flow.
// durationSec=0: not aggregated for nested album lists per spec data
// flow.
refs = append(refs, albumRefFrom(row.Album, artist.Name, int(row.TrackCount), 0))
}
genres, err := q.ListGenresForArtist(r.Context(), artist.ID)
@@ -257,7 +258,7 @@ func (h *handlers) handleGetArtistTopTracks(w http.ResponseWriter, r *http.Reque
// handleLibraryShuffle implements GET /api/library/shuffle?limit=N —
// the online source for the client's always-present "Shuffle all"
// (#427 S4). N random tracks across the whole library, per-user
// N random tracks across the whole library, per-user
// quarantine filtered. limit defaults to 100, clamped to 1..500.
// Offline, the client shuffles its local cache instead and never
// calls this.
+3 -3
View File
@@ -39,11 +39,11 @@ type yearFilter struct {
// the SPA infinite-scrolls against this endpoint via TanStack
// createInfiniteQuery.
//
// Optional filters (#367): `genre` and `year_from`/`year_to`.
// Optional filters: `genre` and `year_from`/`year_to`.
//
// Genre arrives as a QUERY parameter rather than a path segment on purpose.
// Raw ID3 genres routinely contain a slash — "Rock/Pop" is a real tag, and
// the one the task itself cites — which cannot survive a path segment: Go
// Raw ID3 genres routinely contain a slash — "Rock/Pop" is a real tag —
// which cannot survive a path segment: Go
// normalises %2F and the router would split the value into two segments.
func (h *handlers) handleListLibraryAlbums(w http.ResponseWriter, r *http.Request) {
limit, offset, err := parsePaging(r.URL.Query())
+2 -7
View File
@@ -7,18 +7,13 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// genreCount is one row of the genre browse index (#367).
// genreCount is one row of the genre browse index.
//
// Genres are the tag's own strings, split on [;,] but otherwise untouched — no
// case folding and no synonym mapping. So "Rock" and "rock" can both appear,
// as can "Rock/Pop" alongside "Rock" and "Pop". That's deliberate for v1: the
// as can "Rock/Pop" alongside "Rock" and "Pop". That's deliberate: the
// alternative is a normalisation table to invent and maintain, and the raw
// spread has to be visible before anyone can judge whether it's a problem.
//
// The first look at that spread found it dominated by welded tokens like
// "Alternative RockRock" — the scanner's own bug, not the operator's tagging
// (#2499). Judge the "is a taxonomy needed" question (#2468) only against a
// library re-scanned since that fix.
type genreCount struct {
Genre string `json:"genre"`
TrackCount int `json:"track_count"`
+11 -13
View File
@@ -83,10 +83,9 @@ func TestParseYearFilter(t *testing.T) {
}
}
// The crux of #367: a track tagged "Rock;Pop" must be reachable from BOTH
// genres. An exact-string match — which is what ListAlbumsByGenre did before
// this task — makes every multi-genre track invisible from either of its
// genres, so the index would list a genre whose page is empty.
// A track tagged "Rock;Pop" must be reachable from BOTH genres. An
// exact-string match makes every multi-genre track invisible from either of
// its genres, so the index would list a genre whose page is empty.
func TestListGenres_SplitsMultiGenreTags(t *testing.T) {
h, pool := testHandlers(t)
artist := seedArtist(t, pool, "Genre Splitter")
@@ -121,7 +120,7 @@ func TestListGenres_SplitsMultiGenreTags(t *testing.T) {
// Splitting produces leading spaces on every fragment after the first, and
// showing " Pop" as a genre distinct from "Pop" would be a bug. Trimming is a
// repair for our own splitting, not normalisation of the operator's tags.
// repair for our own splitting, not normalisation of the library's tags.
func TestListGenres_TrimsFragmentWhitespace(t *testing.T) {
h, pool := testHandlers(t)
artist := seedArtist(t, pool, "Spacey Tags")
@@ -223,8 +222,8 @@ func TestListLibraryAlbums_GenreWithSlashSurvives(t *testing.T) {
func TestListLibraryAlbums_YearRangeFilter(t *testing.T) {
h, pool := testHandlers(t)
artist := seedArtist(t, pool, "Chronology")
// Each album needs a playable track: since #2702 the year axis lists only
// albums with something to play, matching what the genre axis already did.
// Each album needs a playable track: the year axis, like the genre axis,
// lists only albums with something to play.
for _, a := range []struct {
title string
year int
@@ -341,10 +340,9 @@ func keysOf(m map[string]bool) []string {
return out
}
// #2702: an album whose every file has gone leaves the year axis, matching
// what the genre axis already did. Before this the two disagreed — the same
// album was absent from genre and still listed under its year, where opening
// it found nothing playable.
// An album whose every file has gone leaves the year axis, as it leaves the
// genre axis: otherwise the same album would be absent from its genre and
// still listed under its year, where opening it finds nothing playable.
func TestListAlbumYears_ExcludesFullyMissingAlbums(t *testing.T) {
h, pool := testHandlers(t)
q := dbq.New(pool)
@@ -387,8 +385,8 @@ func TestListAlbumYears_ExcludesFullyMissingAlbums(t *testing.T) {
}
}
// The index, the list and the count must agree. #367 needed care for exactly
// this reason at the genre level: if they diverge, a year leads to an empty
// The index, the list and the count must agree, as at the genre level: if
// they diverge, a year leads to an empty
// page or "Load more" promises rows that never arrive.
func TestListLibraryAlbums_YearFilterAndCountAgreeOnMissing(t *testing.T) {
h, pool := testHandlers(t)
+10 -10
View File
@@ -3,8 +3,8 @@ package api
// Wire shapes for /api/library/sync upserts. The sqlc-generated row
// structs (dbq.Artist, dbq.Album, dbq.Track, dbq.Playlist) have no
// JSON tags (sqlc.yaml: emit_json_tags=false), so a raw json.Marshal
// of them produces PascalCase field names that the Flutter sync
// controller (which reads snake_case keys) can't parse.
// of them produces PascalCase field names, and clients read snake_case
// keys.
//
// These view structs add the JSON tag layer + flatten pgtype.UUID and
// pgtype.Timestamptz / Date into strings, mirroring the pattern
@@ -50,7 +50,7 @@ type albumSyncView struct {
ReleaseDate *string `json:"release_date"`
CoverArtPath *string `json:"cover_art_path"`
Mbid *string `json:"mbid"`
// AlbumGain and AlbumPeak are the album's ReplayGain 2.0 values (#4997):
// AlbumGain and AlbumPeak are the album's ReplayGain 2.0 values:
// dB to the -18 LUFS reference, and a linear peak. Null until every track
// on the album is measured.
AlbumGain *float32 `json:"album_gain"`
@@ -87,20 +87,20 @@ type trackSyncView struct {
FilePath string `json:"file_path"`
FileFormat string `json:"file_format"`
Genre *string `json:"genre"`
// Missing reports that the file is currently absent from disk (#2704).
// Missing reports that the file is currently absent from disk.
//
// Shipped as state rather than filtered out of the feed, because a
// missing file is expected to come back: the scanner clears the mark
// when it does, and adopts the row if it returns under a new name
// (#2528). Dropping the row instead would mean a delete-and-recreate on
// every client for what is often a transient unmount, churning caches
// and discarding the identity #2528 works to preserve.
// when it does, and adopts the row if it returns under a new name.
// Dropping the row instead would mean a delete-and-recreate on every
// client for what is often a transient unmount, churning caches and
// discarding the track's identity.
//
// A bool rather than the timestamp: clients need it to decide whether a
// track is playable, which is a yes/no. The "gone since" clock is an
// operator concern and lives on the admin surface.
// admin concern and lives on the admin surface.
Missing bool `json:"missing"`
// TrackGain and TrackPeak are the track's ReplayGain 2.0 values (#4997).
// TrackGain and TrackPeak are the track's ReplayGain 2.0 values.
// Null until the track is measured. The album's pair rides on the album
// view, since it changes when the album does, not when this track does.
TrackGain *float32 `json:"track_gain"`
+4 -4
View File
@@ -81,9 +81,9 @@ func TestTrackSyncView_WireKeys(t *testing.T) {
})
}
// The mark is what the client filters on, so a wrong value here silently
// re-introduces #2704: a present track marked missing vanishes from the
// client's library, a missing one stays playable and fails at the speaker.
// The mark is what the client filters on, so a wrong value here is silent: a
// present track marked missing vanishes from the client's library, a missing
// one stays playable and fails at the speaker.
func TestTrackSyncView_MissingReflectsTheMark(t *testing.T) {
present := dbq.Track{ID: validUUID, AlbumID: validUUID, ArtistID: validUUID}
if toTrackSyncView(present, library.ReplayGain{}).Missing {
@@ -97,7 +97,7 @@ func TestTrackSyncView_MissingReflectsTheMark(t *testing.T) {
}
}
// The gains are what Android levels playback by, offline included (#4997):
// The gains are what Android levels playback by, offline included:
// the track's pair rides on the track view and the album's on the album view.
func TestSyncViews_CarryReplayGain(t *testing.T) {
gain, peak := float32(-3.25), float32(0.9441)
+6 -4
View File
@@ -169,7 +169,8 @@ func TestHandleGetAlbum_NoTracks(t *testing.T) {
if len(got.Tracks) != 0 {
t.Errorf("tracks len = %d, want 0", len(got.Tracks))
}
// Year omitempty: releaseYear=0 => Year=0 => field should be absent from JSON
// Year omitempty: releaseYear=0 => Year=0 => field should be absent from
// JSON
if got.Year != 0 {
t.Errorf("year = %d, want 0", got.Year)
}
@@ -226,7 +227,8 @@ func TestHandleGetArtist_HappyPath(t *testing.T) {
if len(got.Albums) != 2 {
t.Fatalf("albums len = %d, want 2", len(got.Albums))
}
// ListAlbumsByArtist orders by release_date then sort_title; Abbey Road (1969) first.
// ListAlbumsByArtist orders by release_date then sort_title; Abbey Road
// (1969) first.
if got.Albums[0].Title != "Abbey Road" {
t.Errorf("first album = %q, want Abbey Road", got.Albums[0].Title)
}
@@ -475,10 +477,10 @@ func TestRoutesRegisteredInMount(t *testing.T) {
"/api/tracks/00000000-0000-0000-0000-000000000001",
"/api/tracks/00000000-0000-0000-0000-000000000001/stream",
"/api/search?q=x",
// Browse indexes (#367).
// Browse indexes.
"/api/library/genres",
"/api/library/years",
// Admin surfaces for the missing-file lifecycle (#2527). Both must
// Admin surfaces for the missing-file lifecycle. Both must
// 401 at the middleware rather than 404 — they live inside the
// admin group, so reaching the auth check is what proves they are
// wired.
+4 -4
View File
@@ -16,9 +16,9 @@ import (
// logLikeChange writes a library_changes row for a like / unlike. Best-
// effort: a failure here means the next sync may miss the row, but the
// HTTP response still succeeds. Cheap consequence — operator's other
// device will see the like once it manually pulls or once a future
// like/unlike on the same entity emits a fresh log row.
// HTTP response still succeeds. Cheap consequence — the user's other
// device sees the like once it manually pulls, or once a later like/unlike
// on the same entity emits a fresh log row.
func (h *handlers) logLikeChange(r *http.Request, et syncpkg.EntityType, userID, entityID pgtype.UUID, op syncpkg.Op) {
if err := syncpkg.LogChange(r.Context(), h.pool, et,
syncpkg.EncodeLikeID(syncpkg.FormatUUID(userID), syncpkg.FormatUUID(entityID)), op); err != nil {
@@ -60,7 +60,7 @@ func (h *handlers) handleLikeTrack(w http.ResponseWriter, r *http.Request) {
if slices.Contains(liked, id) {
_ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, id, h.logger)
}
// The like is on the song (M498 #5438): every copy it reached changes on
// The like is on the song: every copy it reached changes on
// the client too.
for _, t := range withTrack(id, liked) {
h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, t, syncpkg.OpUpsert)
+7 -13
View File
@@ -10,20 +10,14 @@ import (
// handleGetMe returns the authenticated user's profile shape — id,
// username, display_name, email, is_admin. Mirrors the response shape
// of PUT /api/me/profile so the Android Settings → Profile screen
// (and Flutter's equivalent) can read its starting state from a GET
// before the user has done a first save.
// of PUT /api/me/profile so the Android Settings → Profile screen can
// read its starting state from a GET before the user has done a first
// save.
//
// Drift #578 fix: this previously emitted the narrower UserView
// (id, username, is_admin only). Android's MeApi.getProfile() deser-
// ialised into MyProfileWire and saw display_name=null, email=null
// on every read — wiping the form fields on every Settings open.
// Saving from that blank state then submitted empty strings, which
// handleUpdateMyProfile treats as "clear to NULL" — DESTROYING the
// user's stored display_name + email. Returning the full profile
// shape here closes the loop. Web (User type narrower than this
// response) keeps working via TypeScript structural typing — the
// extra fields are ignored.
// The full profile matters: a form seeded with display_name=null and
// email=null would, on save, submit empty strings, which
// handleUpdateMyProfile treats as "clear to NULL" — destroying the stored
// values. The web app's narrower User type ignores the extra fields.
func (h *handlers) handleGetMe(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
+1 -1
View File
@@ -11,7 +11,7 @@ import (
)
// GET /api/me/normalization returns the caller's loudness-normalization
// preference, or the defaults when they have never set one (M464 #4998).
// preference, or the defaults when they have never set one.
func (h *handlers) handleGetMyNormalization(w http.ResponseWriter, r *http.Request) {
caller, ok := requireUser(w, r)
if !ok {
+5 -4
View File
@@ -18,7 +18,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
// The notifications inbox (M489). Rows are written by internal/notifications;
// The notifications inbox. Rows are written by internal/notifications;
// this surface lists them, counts the unread, marks them read, and holds each
// user's per-kind settings.
@@ -125,8 +125,8 @@ func (h *handlers) handleListMyNotifications(w http.ResponseWriter, r *http.Requ
writeJSON(w, http.StatusOK, out)
}
// handleMyUnreadNotificationCount implements GET /api/me/notifications/unread-count,
// the cheap call behind the badge.
// handleMyUnreadNotificationCount implements GET
// /api/me/notifications/unread-count, the cheap call behind the badge.
func (h *handlers) handleMyUnreadNotificationCount(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
@@ -165,7 +165,8 @@ func (h *handlers) handleMarkMyNotificationRead(w http.ResponseWriter, r *http.R
w.WriteHeader(http.StatusNoContent)
}
// handleMarkAllMyNotificationsRead implements POST /api/me/notifications/read-all.
// handleMarkAllMyNotificationsRead implements POST
// /api/me/notifications/read-all.
func (h *handlers) handleMarkAllMyNotificationsRead(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
+1 -1
View File
@@ -37,7 +37,7 @@ type meProfileResp struct {
IsAdmin bool `json:"is_admin"`
// DebugModeEnabled is the account's diagnostics opt-in. The client
// reads it here to decide whether to run the on-device diagnostics
// reporter (M9). Admin-set; the client also has a local OFF switch.
// reporter. Admin-set; the client also has a local OFF switch.
DebugModeEnabled bool `json:"debug_mode_enabled"`
}
+6 -6
View File
@@ -21,7 +21,7 @@ const (
recMetricsLowVolume = 20
)
// Surface intents (milestone #127): each family is judged against its
// Surface intents: each family is judged against its
// job, not one global bar — discovery mixes are EXPECTED to run higher
// skip rates than the go-to surfaces.
const (
@@ -40,15 +40,15 @@ type surfaceMetric struct {
AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1]
LowConfidence bool `json:"low_confidence"` // plays < recMetricsLowVolume
// SkipDelta / CompletionDelta are this row's difference from the manual
// baseline WITH its margin of error (#2495). nil on the baseline row
// baseline WITH its margin of error. nil on the baseline row
// itself, and whenever the samples are too thin for a margin to mean
// anything. Computed server-side so both clients read the same arithmetic
// instead of each re-deriving it — and so `low_confidence` is no longer
// mistaken for a decision threshold, which it never was.
// instead of each re-deriving it. `low_confidence` is a display
// threshold, not a decision one.
SkipDelta *metricDelta `json:"skip_delta,omitempty"`
CompletionDelta *metricDelta `json:"completion_delta,omitempty"`
// Breakdown splits the family into the pick-kind populations its
// builder stamped (#1249, generalized #1270): For You's taste/fresh,
// builder stamped: For You's taste/fresh,
// Discover's buckets, tier1-3 for tiered mixes — plus earlier plays
// that predate attribution. Present only when the family has at
// least one attributed play; the parent row remains the sum of its
@@ -128,7 +128,7 @@ type familyAccum struct {
// reduces to a single weighted division at the end.
completionSum float64
// completionSqSum is the sum of squared completion ratios, which is what
// makes the variance mergeable across raw source rows (#2495). Standard
// makes the variance mergeable across raw source rows. Standard
// deviations cannot be combined; sums of squares add exactly.
completionSqSum float64
}
@@ -23,7 +23,7 @@ func newMetricsRouter(h *handlers) chi.Router {
// seedSourcedPlay inserts a play_event with an explicit source + completion +
// skip flag. A nil source inserts NULL (manual library play → baseline).
// pickKind is the For You taste/fresh attribution (#1249); nil everywhere
// pickKind is the For You taste/fresh attribution; nil everywhere
// except attributed for_you plays.
func seedSourcedPlay(
t *testing.T, h *handlers, userID, trackID, sessionID pgtype.UUID,
@@ -169,7 +169,7 @@ func TestBucketMetricsResponse_ForYouBreakdown(t *testing.T) {
}
func TestBucketMetricsResponse_DiscoverBucketBreakdown(t *testing.T) {
// Provenance is standard (#1270): Discover's bucket stamps surface as
// Provenance is standard: Discover's bucket stamps surface as
// a breakdown exactly like For You's taste/fresh — this is what makes
// the bucket allocation judgeable instead of a guess.
src := func(s string) *string { return &s }
@@ -225,7 +225,7 @@ func TestRecommendationMetrics_BucketsWithBaseline(t *testing.T) {
radioA := "radio:11111111-1111-1111-1111-111111111111"
taste, fresh := "taste", "fresh"
// for_you: 3 plays, 1 skipped; completions 1.0, 0.95, 0.05 → mean 0.6667.
// Two attributed as taste picks, the skipped one as a fresh pick (#1249).
// Two attributed as taste picks, the skipped one as a fresh pick.
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &taste, 1.0, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &taste, 0.95, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &fresh, 0.05, true)
@@ -265,7 +265,7 @@ func TestRecommendationMetrics_BucketsWithBaseline(t *testing.T) {
if fy.AvgCompletion < 0.66 || fy.AvgCompletion > 0.67 {
t.Errorf("for_you avg_completion = %.4f, want ~0.6667", fy.AvgCompletion)
}
// Pick-kind attribution surfaces as the For You breakdown (#1249).
// Pick-kind attribution surfaces as the For You breakdown.
if len(fy.Breakdown) != 2 {
t.Fatalf("for_you breakdown rows = %d, want 2 (taste, fresh)", len(fy.Breakdown))
}
+2 -2
View File
@@ -49,8 +49,8 @@ func withURLParam(r *http.Request, key, value string) *http.Request {
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
// The rule #47 assertion. A delete keyed only on session id would let any
// household member revoke any other member's session by id — this pins that
// Household data stays per-user: a delete keyed only on session id would let
// any household member revoke any other member's session by id — this pins that
// the user scope is actually in the WHERE clause and not just intended.
func TestRevokeMySession_CannotRevokeAnotherUsersSession(t *testing.T) {
h, pool := testHandlers(t)
+1 -2
View File
@@ -15,8 +15,7 @@ import (
// itself, so it is stored readable (migration 0003). That is why Minstrel
// generates it rather than letting the user choose one: a generated value
// can never be a login password reused from somewhere else, so a leaked
// users table gives up access to this server's /rest API and nothing more
// (M462 #5026).
// users table gives up access to this server's /rest API and nothing more.
const subsonicPasswordBytes = 18 // 24 base64url characters
+4 -3
View File
@@ -19,9 +19,10 @@ type systemPlaylistsStatusResp struct {
LastError *string `json:"last_error"`
}
// handleGetSystemPlaylistsStatus implements GET /api/me/system-playlists-status.
// Returns the caller's system_playlist_runs row, or zero-values when no row
// exists yet (i.e. the user has never had a build attempted).
// handleGetSystemPlaylistsStatus implements GET
// /api/me/system-playlists-status. Returns the caller's system_playlist_runs
// row, or zero-values when no row exists yet (i.e. the user has never had a
// build attempted).
func (h *handlers) handleGetSystemPlaylistsStatus(w http.ResponseWriter, r *http.Request) {
caller, ok := requireUser(w, r)
if !ok {
+4 -7
View File
@@ -37,13 +37,10 @@ func TestHandleGetMe_ReturnsAuthenticatedUser(t *testing.T) {
}
}
// Drift #578 regression guard: a user whose profile fields ARE set
// must see them in the /api/me response. Previously this handler
// emitted the narrower UserView so Android Settings → Profile saw
// display_name=null and email=null on every read, wiped the form
// fields, and submitting from that blank state destroyed the user's
// stored values via the "empty string clears to NULL" semantics of
// PUT /api/me/profile.
// A user whose profile fields ARE set must see them in the /api/me
// response: a Settings → Profile form seeded without them would, on save,
// clear the stored values through PUT /api/me/profile's "empty string
// clears to NULL".
func TestHandleGetMe_IncludesDisplayNameAndEmail(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", false)
+4 -7
View File
@@ -1,11 +1,8 @@
// PUT /api/me/timezone — accept the client's IANA timezone and store
// it on the user row. The system-playlist scheduler (#392 Half B)
// reads this column to fire each user's daily 03:00 build in their
// local time.
//
// Scheduler.Refresh(ctx, userID) is invoked in a later commit (Task 3)
// so a timezone change takes effect synchronously rather than waiting
// for the hourly reconciliation pass.
// it on the user row. The system-playlist scheduler reads this column
// to fire each user's daily 03:00 build in their local time. The
// handler calls Scheduler.Refresh so a change takes effect at once rather
// than at the hourly reconciliation pass.
package api
+8 -11
View File
@@ -125,17 +125,14 @@ func (h *handlers) handleGetCover(w http.ResponseWriter, r *http.Request) {
// stream — either via the standard session path (cookie OR bearer token
// resolved to a user-in-context by auth.OptionalUser, the middleware
// the route is wrapped with in api.go) OR via a valid short-lived HMAC
// token in the ?token=&exp= query (UPnP / Sonos path, new for the
// output-picker UPnP slice).
// token in the ?token=&exp= query (the UPnP / Sonos path).
//
// The token path lets network speakers fetch the stream URL without
// carrying the user's session cookie — they cannot. See the design at
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
// carrying the user's session cookie — they cannot.
//
// When h.streamSecret is nil (slice-1 default, until slice 2 wires the
// loader), the token path always rejects because the HMAC of an empty
// key won't match any token a client could mint. The session path keeps
// working.
// When h.streamSecret is nil the token path always rejects, because the
// HMAC of an empty key won't match any token a client could mint. The
// session path keeps working.
func (h *handlers) streamAuthOk(r *http.Request, trackID string) bool {
if _, ok := auth.UserFromContext(r.Context()); ok {
return true
@@ -194,9 +191,9 @@ func (h *handlers) handleGetStream(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Accept-Ranges", "bytes")
// Track bytes are immutable for a given track id (the scanner
// indexes by file_path; re-encoded files take new ids). One-year
// max-age + immutable lets the client cache (LockCachingAudioSource
// on the Flutter side, browser cache on web) skip even the
// conditional GET on repeat plays.
// max-age + immutable lets the client cache (Media3's cache on Android,
// the browser cache on web) skip even the conditional GET on repeat
// plays.
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)
}
+15 -22
View File
@@ -2,27 +2,19 @@ package api
import "math"
// Uncertainty on the deltas the recommendation-metrics card shows (#2495).
// Uncertainty on the deltas the recommendation-metrics card shows.
//
// Why this exists: the card had exactly one volume threshold,
// recMetricsLowVolume = 20, and it was doing two jobs. Twenty plays is enough to
// be worth DISPLAYING — below that a skip rate is anecdote — but it is nowhere
// near enough to ACT on. Detecting the ~13pp differences that actually matter
// needs roughly 133 plays per arm for 80% power at α=0.05.
// recMetricsLowVolume = 20 answers "is this worth DISPLAYING" — below that a
// skip rate is anecdote — but twenty plays is nowhere near enough to ACT on.
// Detecting the ~13pp differences that actually matter needs roughly 133
// plays per arm for 80% power at α=0.05, and two arms of 60-70 plays each
// can look like a bold difference while sitting at p ≈ 0.06.
//
// So Discover's taste-matched (59 plays) and random-unheard (70) both rendered as
// full-confidence rows with a bold delta beside them, and that comparison sits at
// p ≈ 0.06. The card said "signal"; the arithmetic said "maybe". It led directly
// to a recommendation the data didn't support, and any reader with the same
// numbers would have made the same call.
//
// The fix is to publish the margin of error next to the delta and flag when the
// delta is smaller than it — i.e. not distinguishable from zero. Computed here,
// server-side, so both clients agree rather than each re-deriving it.
//
// recMetricsLowVolume stays exactly as it was. This is a second, independent
// signal, not a replacement: "too thin to show" and "too thin to act on" are
// different questions and deserve different answers.
// So each delta carries its margin of error, and a flag when the delta is
// smaller than it — not distinguishable from zero. Computed here,
// server-side, so both clients agree rather than each re-deriving it. "Too
// thin to show" and "too thin to act on" are different questions and get
// different answers.
// deltaZ is the two-sided 95% normal critical value. Normal rather than
// Student's t: at the sample sizes where a delta is worth acting on (n in the
@@ -50,7 +42,8 @@ type metricDelta struct {
// proportionDelta compares two rates (skips/plays) as a two-proportion
// difference. Returns nil when either sample is empty, or when either rate is
// degenerate (0 or 1) — a rate with no observed variation has an SE of 0 on its
// side, which would report a spuriously narrow margin rather than an honest one.
// side, which would report a spuriously narrow margin rather than an honest
// one.
func proportionDelta(rate1 float64, n1 int64, rate2 float64, n2 int64) *metricDelta {
if n1 <= 0 || n2 <= 0 {
return nil
@@ -98,8 +91,8 @@ func newDelta(deltaPP, marginPP float64) *metricDelta {
}
// sampleVariance recovers the sample variance from the aggregates the SQL
// returns. sum is mean×n rather than a selected column, which keeps the query to
// one extra expression.
// returns. sum is mean×n rather than a selected column, which keeps the query
// to one extra expression.
//
// The subtraction can go very slightly negative through floating-point
// cancellation when every observation is identical, so the result is clamped —
+6 -6
View File
@@ -6,9 +6,8 @@ import (
)
func TestProportionDelta_ReproducesTheDiscoverCase(t *testing.T) {
// The comparison that motivated #2495: Discover taste-matched (59 plays,
// 15.3% skip) vs random-unheard (70 plays, 28.6%). A 13.3pp gap that the old
// card rendered as a confident coloured number, sitting at p ≈ 0.06.
// 59 plays at 15.3% skip vs 70 at 28.6%: a 13.3pp gap that looks
// confident and sits at p ≈ 0.06.
d := proportionDelta(0.153, 59, 0.286, 70)
if d == nil {
t.Fatal("expected a delta for two real samples")
@@ -16,8 +15,8 @@ func TestProportionDelta_ReproducesTheDiscoverCase(t *testing.T) {
if math.Abs(d.DeltaPP-(-13.3)) > 0.1 {
t.Errorf("DeltaPP = %.2f, want ≈ -13.3", d.DeltaPP)
}
// This is the assertion the whole task exists for: at these sample sizes the
// margin swallows the difference.
// This is the assertion the whole task exists for: at these sample sizes
// the margin swallows the difference.
if d.Distinguishable {
t.Errorf("13.3pp on n=59/70 reported as distinguishable (margin %.2f) — "+
"this is exactly the false confidence #2495 set out to remove", d.MarginPP)
@@ -70,7 +69,8 @@ func TestProportionDelta_DegenerateRates(t *testing.T) {
if d := proportionDelta(1, 50, 1, 50); d != nil {
t.Errorf("both rates 1 produced a delta: %+v", d)
}
// One degenerate side is still informative — the other side carries variance.
// One degenerate side is still informative — the other side carries
// variance.
if d := proportionDelta(0, 200, 0.3, 200); d == nil {
t.Error("one degenerate rate should still yield a delta")
}
+2 -2
View File
@@ -11,7 +11,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
)
// Helpers for the handlers that produce notifications (M489). Every call is
// Helpers for the handlers that produce notifications. Every call is
// NotifyLogged: a notification never fails the action that caused it.
// userLabel is how a user is named to someone else: their display name, or
@@ -74,7 +74,7 @@ func (h *handlers) trackLabel(ctx context.Context, trackID pgtype.UUID) string {
}
// notifyPlaybackErrors tells admins a track failed to play, naming it and who
// it failed for (M542). The notice coalesces: while it is unread each report
// it failed for. The notice coalesces: while it is unread each report
// adds one to its count and its name to the front of the list.
func (h *handlers) notifyPlaybackErrors(ctx context.Context, trackID pgtype.UUID, user dbq.User) {
p := notifications.Payload{Count: 1}
+2 -2
View File
@@ -15,8 +15,8 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
// inboxItem is one thing the notifier left for one user: kind and decoded payload,
// newest first.
// inboxItem is one thing the notifier left for one user: kind and decoded
// payload, newest first.
type inboxItem struct {
Kind string
Payload notifications.Payload
+4 -3
View File
@@ -60,7 +60,7 @@ type playlistTrackView struct {
DurationSec int32 `json:"duration_sec"`
StreamURL *string `json:"stream_url"`
AddedAt string `json:"added_at"`
// Unavailable marks an entry whose file is missing from disk (#2527).
// Unavailable marks an entry whose file is missing from disk.
// The track is still a real, known track — its history and likes are
// intact and the file may come back — so the entry keeps its place and
// its text; clients render it greyed out and skip over it on playback.
@@ -316,8 +316,9 @@ func (h *handlers) handleAppendTracks(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, playlistDetailToView(detail))
}
// handleRemovePlaylistTrack implements DELETE /api/playlists/{id}/tracks/{position}.
// Owner only. Returns the post-mutation detail like AppendTracks.
// handleRemovePlaylistTrack implements DELETE
// /api/playlists/{id}/tracks/{position}. Owner only. Returns the post-mutation
// detail like AppendTracks.
func (h *handlers) handleRemovePlaylistTrack(w http.ResponseWriter, r *http.Request) {
caller, ok := requireUser(w, r)
if !ok {
+3 -4
View File
@@ -14,15 +14,14 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
)
// Generic registry-driven system-playlist endpoints (#411 R2),
// replacing the former per-kind handlers. {kind} is the raw
// Generic registry-driven system-playlist endpoints. {kind} is the raw
// system_variant ('for_you' | 'discover' | future singleton kinds);
// only singleton kinds (playlists.RefreshableSystemKind) are
// addressable here — songs_like_artist is multi-per-user and played
// by playlist id, so it 404s.
//
// GET /api/playlists/system/{kind}/shuffle — the caller's playlist
// for that kind with tracks in rotation-aware order (#415): unheard
// for that kind with tracks in rotation-aware order: unheard
// this rotation first (shuffled), then heard; resets when exhausted.
// Same JSON shape as GET /api/playlists/{id} so clients reuse track
// parsing. Intentionally uncached — it varies per play, unlike the
@@ -73,7 +72,7 @@ func (h *handlers) handleSystemPlaylistRefresh(w http.ResponseWriter, r *http.Re
writeErr(w, apierror.InternalMsg("build failed", err))
return
}
// #968: announce the rebuild so the user's other clients refresh.
// Announce the rebuild so the user's other clients refresh.
h.publishSystemRebuilt(user.ID)
q := dbq.New(h.pool)
v := kind
+1 -2
View File
@@ -10,8 +10,7 @@ import (
"github.com/go-chi/chi/v5"
)
// Generic registry-driven system-playlist endpoints (#411 R2).
// Replaces the former per-kind refresh handler tests.
// Generic registry-driven system-playlist endpoints.
func newSystemPlaylistRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Route("/api", func(api chi.Router) {
+4 -2
View File
@@ -571,13 +571,15 @@ func TestListPlaylists_BadKindReturns400(t *testing.T) {
}
// TestEditEndpoints_403OnSystemPlaylist verifies that all five edit verbs
// return 403 system_playlist_readonly when the target playlist has kind='system'.
// return 403 system_playlist_readonly when the target playlist has
// kind='system'.
func TestEditEndpoints_403OnSystemPlaylist(t *testing.T) {
h, pool := testHandlers(t)
u := seedUser(t, pool, "sysedit", "pw", false)
// Seed a system playlist directly via SQL (BuildSystemPlaylists requires
// play_events seeding which we already exercise in playlists package tests).
// play_events seeding which we already exercise in playlists package
// tests).
var sid string
if err := pool.QueryRow(context.Background(), `
INSERT INTO playlists (user_id, name, description, is_public, kind, system_variant)
+1 -1
View File
@@ -71,7 +71,7 @@ func (h *handlers) handleFlag(w http.ResponseWriter, r *http.Request) {
// Broadcast: the flagging user's other clients invalidate their
// Hidden tab; admins' clients invalidate their quarantine queue.
h.publishQuarantineEvent("quarantine.flagged", user.ID, trackID, true)
// Admins review flags (M489). An admin flagging a track needs no notice
// Admins review flags. An admin flagging a track needs no notice
// of their own flag.
h.notifier.NotifyLogged(r.Context(), notifications.KindQuarantineFlagged, notifications.ToAdmins(user.ID),
notifications.Payload{Name: h.trackLabel(r.Context(), trackID), Actor: userLabel(user), Reason: quarantineReasonLabel(body.Reason)}.Map())
+9 -11
View File
@@ -82,16 +82,14 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
}
currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger)
// Condition on the current device (#1551): the latest play's device is a
// Condition on the current device: the latest play's device is a
// cheap, request-free proxy for what the user is on right now.
currentVec.DeviceClass = latestDeviceClass(r.Context(), q, user.ID, h.logger)
exclude := parseExcludeParam(r.URL.Query().Get("exclude"))
// Size the pool to the library (#3880). A fixed ~170 candidates samples a
// shrinking fraction of a growing collection, which is what made the
// recommendations feel less relevant as the library grew. Degrades to the
// base limits if the count is unavailable — never fails the request over a
// sizing hint.
// Size the pool to the library: a fixed ~170 candidates samples a shrinking
// fraction of a growing collection. Degrades to the base limits if the
// count is unavailable — never fails the request over a sizing hint.
librarySize := h.librarySize.Get(r.Context(), func(ctx context.Context) (int64, error) {
return recommendation.CountLibraryTracks(ctx, q)
})
@@ -101,7 +99,7 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
candidates, err := recommendation.LoadCandidatesFromSimilarity(
r.Context(), q, user.ID, seedID,
h.recCfg.RecentlyPlayedHours, currentVec, exclude, limits,
// A fresh seed per request (#3889): radio is a new session each time
// A fresh seed per request: radio is a new session each time
// and SHOULD draw differently. The system mixes are the surfaces that
// promise repeatability; this is not one of them.
strconv.FormatInt(time.Now().UnixNano(), 36),
@@ -119,11 +117,11 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
}
}
// Scoring weights come from the DB-backed tuning lab (#1250) —
// Scoring weights come from the DB-backed tuning —
// read per request so an admin change takes effect live.
weights := h.recSettings.Weights(recsettings.ScopeRadio)
// Diversity caps (#3882). Radio had none while every sibling surface did,
// which is how a whole session could come back from one artist. Scaled to
// Diversity caps, so a whole session cannot come back from one artist.
// Scaled to
// the requested length so a 20-track radio and a 200-track one are capped
// alike; Shuffle relaxes them rather than returning a short radio.
//
@@ -177,7 +175,7 @@ func loadCurrentSessionVector(r *http.Request, q *dbq.Queries, userID pgtype.UUI
}
// latestDeviceClass returns the device_class of the user's most recent play as
// the "current device" for context conditioning (#1551), or "" when unknown
// the "current device" for context conditioning, or "" when unknown
// (no plays yet, or the latest play predates device capture). Best-effort: a
// lookup failure yields a device-agnostic ("") affinity cell.
func latestDeviceClass(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, logger *slog.Logger) string {
+2 -2
View File
@@ -19,8 +19,8 @@ type replayGainResp struct {
Items map[string]library.ReplayGain `json:"items"`
}
// handleGetReplayGain implements GET /api/tracks/replay-gain?ids=a,b,c (M464
// #4997): ReplayGain 2.0 values (dB to -18 LUFS, linear peaks) for each track,
// handleGetReplayGain implements GET /api/tracks/replay-gain?ids=a,b,c:
// ReplayGain 2.0 values (dB to -18 LUFS, linear peaks) for each track,
// and for its album when the whole album is measured.
//
// A lookup rather than fields on TrackRef. Tracks reach the player through
+7 -6
View File
@@ -150,7 +150,7 @@ func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
return
}
// Auto-approve flow (#355 / #376 U2.5): when an admin has set the user's
// Auto-approve: when an admin has set the user's
// auto_approve_requests flag, the new request goes straight onto the
// wanted list as if approved. A failure leaves it pending for an admin,
// the same as a manual approval hitting the same error. The approver is
@@ -168,10 +168,10 @@ func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
}
}
// A new request still pending after any auto-approval waits on an admin
// (M489). One that deduped into a request already in flight was
// announced when it was first made, and the requester, if they are an
// admin themselves, needs no notice of their own request.
// A new request still pending after any auto-approval waits on an admin.
// One that deduped into a request already in flight was announced when it
// was first made, and the requester, if they are an admin themselves,
// needs no notice of their own request.
if created && row.Status == dbq.RequestStatusPending {
p := requestPayload(row)
p.Actor = userLabel(user)
@@ -182,7 +182,8 @@ func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, requestViewFrom(row))
}
// handleListRequests implements GET /api/requests — returns caller's own requests.
// handleListRequests implements GET /api/requests — returns caller's own
// requests.
func (h *handlers) handleListRequests(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
+16 -11
View File
@@ -27,12 +27,14 @@ func newRequestsRouter(h *handlers) chi.Router {
return r
}
// withUser injects a user into the request context the same way RequireUser does.
// withUser injects a user into the request context the same way RequireUser
// does.
func withUser(req *http.Request, user dbq.User) *http.Request {
return req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user))
}
// doCreateRequest fires POST /api/requests with the given JSON body as the given user.
// doCreateRequest fires POST /api/requests with the given JSON body as the
// given user.
func doCreateRequest(h *handlers, user dbq.User, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/api/requests", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
@@ -69,8 +71,8 @@ func doCancelRequest(h *handlers, user dbq.User, id pgtype.UUID) *httptest.Respo
return w
}
// createArtistRequest is a convenience wrapper that seeds a valid artist request
// via the handler and returns the parsed response.
// createArtistRequest is a convenience wrapper that seeds a valid artist
// request via the handler and returns the parsed response.
func createArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, artistName string) requestView {
t.Helper()
body := fmt.Sprintf(`{"kind":"artist","artist_mbid":%q,"artist_name":%q}`, artistMBID, artistName)
@@ -87,7 +89,7 @@ func createArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, a
// TestHandleCreateRequest_AutoApprove_GoesStraightOnTheWantedList verifies
// that a user with auto_approve_requests=true gets their request approved on
// creation (#355 / #376 U2.5): an artist request follows the artist at once.
// creation: an artist request follows the artist at once.
func TestHandleCreateRequest_AutoApprove_GoesStraightOnTheWantedList(t *testing.T) {
h, pool := testHandlers(t)
resetRequests(t, h)
@@ -239,7 +241,8 @@ func TestHandleListRequests_OnlyOwnRequests(t *testing.T) {
}
}
// TestHandleGetRequest_OwnReturns200 verifies a caller can fetch their own request.
// TestHandleGetRequest_OwnReturns200 verifies a caller can fetch their own
// request.
func TestHandleGetRequest_OwnReturns200(t *testing.T) {
h, pool := testHandlers(t)
resetRequests(t, h)
@@ -351,10 +354,11 @@ func TestHandleCancelRequest_NonPending_409(t *testing.T) {
t.Fatalf("first cancel: status = %d, want 200; body = %s", w1.Code, w1.Body.String())
}
// Second cancel on a now-rejected row → ErrNotPending → 409.
// The cancel SQL checks user_id = $2 AND status = 'pending'; zero rows → ErrNotPending.
// For this test we use dbq directly to reject via admin so Cancel returns ErrNotPending
// rather than the ownership miss. We already did first cancel so row is rejected.
// Second cancel on a now-rejected row → ErrNotPending → 409. The cancel SQL
// checks user_id = $2 AND status = 'pending'; zero rows → ErrNotPending.
// For this test we use dbq directly to reject via admin so Cancel returns
// ErrNotPending rather than the ownership miss. We already did first cancel
// so row is rejected.
w2 := doCancelRequest(h, alice, rv.ID)
if w2.Code != http.StatusConflict {
t.Fatalf("second cancel: status = %d, want 409; body = %s", w2.Code, w2.Body.String())
@@ -383,7 +387,8 @@ func TestHandleCancelRequest_WrongUser_409(t *testing.T) {
rv := createArtistRequest(t, h, alice, "cancel-wrong-user-mbid", "Alice's Artist")
// Bob tries to cancel Alice's request: SQL WHERE user_id=bob AND status=pending → 0 rows.
// Bob tries to cancel Alice's request: SQL WHERE user_id=bob AND
// status=pending → 0 rows.
w := doCancelRequest(h, bob, rv.ID)
if w.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409; body = %s", w.Code, w.Body.String())
+3 -2
View File
@@ -127,7 +127,8 @@ func (h *handlers) resolveAlbumRefs(ctx context.Context, q *dbq.Queries, albums
h.logger.Error("api: count tracks for album failed", "err", cerr)
return nil, cerr
}
// durationSec=0: not aggregated for search album refs per spec data flow.
// durationSec=0: not aggregated for search album refs per spec data
// flow.
items = append(items, albumRefFrom(a, names[uuidToString(a.ArtistID)], int(count), 0))
}
return items, nil
@@ -135,7 +136,7 @@ func (h *handlers) resolveAlbumRefs(ctx context.Context, q *dbq.Queries, albums
// resolveTrackRefs builds TrackRefs from dbq.Track rows: looks up the
// parent album + artist by id for each track (pair of queries per track).
// At limit=200 this is 400 queries worst case — acceptable for M1.5.
// At limit=200 this is 400 queries worst case.
func (h *handlers) resolveTrackRefs(ctx context.Context, q *dbq.Queries, tracks []dbq.Track) ([]TrackRef, error) {
items := make([]TrackRef, 0, len(tracks))
for _, t := range tracks {
+3 -4
View File
@@ -15,10 +15,9 @@ import (
// trackID is the canonical track UUID. exp is the Unix time after which
// the token is invalid; the handler checks exp before serving.
//
// The secret comes from MINSTREL_STREAM_SECRET (or its auto-generated +
// persisted fallback in app_preferences). The loader lands in slice 2
// (POST /api/cast/stream-token); this file is the primitive both the
// loader and the stream handler share.
// The secret comes from MINSTREL_STREAM_SECRET, or its auto-generated
// fallback persisted under the data directory. This file is the primitive
// the token endpoint and the stream handler share.
func SignStreamToken(secret []byte, trackID string, exp int64) string {
mac := hmac.New(sha256.New, secret)
// hash.Hash.Write is documented as never returning an error.
+11 -11
View File
@@ -23,13 +23,13 @@ type suggestionView struct {
Score float64 `json:"score"`
Attribution []seedContributionView `json:"attribution"`
// MatchedTags are the candidate's tags that overlap the user's taste
// profile, strongest first (#2377) — the "matches: shoegaze, melancholic"
// profile, strongest first — the "matches: shoegaze, melancholic"
// line. Omitted when empty, which is common: tag coverage for
// out-of-library artists is permanently partial (#2376), and the card
// falls back to the seed attribution it has always shown.
// out-of-library artists is permanently partial, and the card falls back
// to the seed attribution.
MatchedTags []string `json:"matched_tags,omitempty"`
// ImageURL is Minstrel's catalogue art endpoint for the candidate
// (out-of-library artists have no local art row, M502 #5519). It
// (out-of-library artists have no local art row). It
// answers 404 when no enabled source has an image, and the client
// falls back to a placeholder.
ImageURL string `json:"image_url,omitempty"`
@@ -75,7 +75,7 @@ func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request)
}
// Read the tuned weight per request so an admin change takes effect on the
// next refresh, no restart (rule #25).
// next refresh, no restart.
tagWeight := h.recSettings.Discover().TagOverlapWeight
suggestions, err := recommendation.SuggestArtists(
r.Context(), h.pool, user.ID, halfLife, limit, tagWeight)
@@ -107,8 +107,8 @@ func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request)
}
// maxSnoozeDays caps a client-supplied duration. The DEFAULT is not here: it's
// a DB-backed knob on the admin tuning card (rule #25), read per request via
// recSettings.Discover().SnoozeDays. See #2377.
// a DB-backed knob on the admin tuning card, read per request via
// recSettings.Discover().SnoozeDays.
const maxSnoozeDays = 365.0
// snoozeRequest is the POST body. Both fields are optional in the JSON sense
@@ -136,8 +136,8 @@ type snoozeView struct {
// snoozing an already-snoozed candidate extends it rather than conflicting.
//
// This is NOT negative feedback. It records no verdict on the artist and is
// never read by internal/taste — see 0049_suggestion_snoozes.up.sql for the
// rule #101 reasoning. Returns 204.
// never read by internal/taste — see 0049_suggestion_snoozes.up.sql for why.
// Returns 204.
func (h *handlers) handleSnoozeSuggestion(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
@@ -222,7 +222,7 @@ func (h *handlers) handleUnsnoozeSuggestion(w http.ResponseWriter, r *http.Reque
//
// The un-snooze surface needs this: a parked candidate is by definition
// absent from the suggestion deck, so without a list there is no way to
// reach the DELETE above. Scoped to the caller (rule #47). Expired rows are
// reach the DELETE above. Scoped to the caller. Expired rows are
// already filtered by the query — the hourly gc sweep only reclaims space.
func (h *handlers) handleListSuggestionSnoozes(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
@@ -248,7 +248,7 @@ func (h *handlers) handleListSuggestionSnoozes(w http.ResponseWriter, r *http.Re
}
// resolveSuggestionArt points each suggestion's ImageURL at Minstrel's
// catalogue art endpoint (M502 #5519). Nothing is fetched here: the image is
// catalogue art endpoint. Nothing is fetched here: the image is
// looked up from the enabled cover-art sources when the browser asks for it,
// and kept, so listing suggestions makes no outbound call. A candidate with
// no MBID keeps an empty ImageURL and the client renders its placeholder.
+8 -7
View File
@@ -7,7 +7,7 @@ import (
)
// LoginRequest is the POST /api/auth/login body. Kept boring on purpose —
// web and Flutter both send the same payload.
// web and Android send the same payload.
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
@@ -15,7 +15,8 @@ type LoginRequest struct {
// LoginResponse carries the opaque session token AND the authenticated user
// so the SPA can hydrate its auth store in one round-trip. The token is also
// set as a cookie; SPAs ignore the body token, Flutter uses it as bearer.
// set as a cookie; the SPA ignores the body token, a native client may send
// it as a bearer.
type LoginResponse struct {
Token string `json:"token"`
User UserView `json:"user"`
@@ -82,7 +83,7 @@ type TrackRef struct {
DiscNumber int `json:"disc_number,omitempty"`
DurationSec int `json:"duration_sec"`
StreamURL string `json:"stream_url"`
// Unavailable reports that the file is missing from disk (#2704).
// Unavailable reports that the file is missing from disk.
//
// Present on every TrackRef rather than only where it can be true: the
// selection surfaces (recommendation, discover, mixes, browse) filter
@@ -99,11 +100,11 @@ type TrackRef struct {
type ArtistDetail struct {
ArtistRef
Albums []AlbumRef `json:"albums"`
// Genres carried by this artist's tracks, for quick-jump chips (#367).
// Genres carried by this artist's tracks, for quick-jump chips.
// Always non-nil at JSON so the client can iterate without a null check.
Genres []string `json:"genres"`
// MBID is the artist's MusicBrainz id, which following is keyed by
// (M502 #5520). Empty when the tags never named one.
// MBID is the artist's MusicBrainz id, which following is keyed by.
// Empty when the tags never named one.
MBID string `json:"mbid"`
}
@@ -111,7 +112,7 @@ type ArtistDetail struct {
type AlbumDetail struct {
AlbumRef
Tracks []TrackRef `json:"tracks"`
// Genres carried by this album's tracks, for quick-jump chips (#367).
// Genres carried by this album's tracks, for quick-jump chips.
// Derived from the tracks rather than stored on the album, because genre
// lives on tracks and an album's tracks can disagree. Non-nil at JSON.
Genres []string `json:"genres"`
+8 -5
View File
@@ -112,9 +112,10 @@ func Default() Config {
RadioSizeMax: 200,
},
Library: LibraryConfig{
// On by default so a fresh install populates its library on
// first boot with no manual step. Scans are incremental (unchanged files skipped by
// mtime), so every later restart is just a cheap directory walk.
// On by default so a fresh install populates its library on first
// boot with no manual step. Scans are incremental (unchanged files
// skipped by mtime), so every later restart is just a cheap
// directory walk.
ScanOnStartup: true,
CoverArtFromMBCAA: true,
},
@@ -151,13 +152,15 @@ const streamSecretBytes = 64
const streamSecretFile = "stream_secret"
// resolveStreamSecret populates cfg.StreamSecret using, in order:
// 1. MINSTREL_STREAM_SECRET env var (base64-url encoded — accepts either padded or raw form).
// 1. MINSTREL_STREAM_SECRET env var (base64-url encoded — accepts either
// padded or raw form).
// 2. <Storage.DataDir>/stream_secret if present (auto-generated on a
// previous boot; survives restarts so signed URLs stay valid).
// 3. Auto-generate streamSecretBytes random bytes, persist them to that
// file at 0600, then use them.
//
// Logs (only) when an auto-generation happens, so it shows on first boot. Never logs the contents.
// Logs (only) when an auto-generation happens, so it shows on first boot. Never
// logs the contents.
//
// The loader runs before slog is initialized in cmd/minstrel/main.go;
// log.Printf is the project's pre-logger convention.
+3 -3
View File
@@ -150,9 +150,9 @@ func TestListDeepCutsTracks_SkippedTracksExcluded(t *testing.T) {
}
func TestListOnThisDayTracks_WrapsYearBoundary(t *testing.T) {
// The day-of-year window wraps the year: plain ABS would make Dec 28 vs
// Jan 3 read as 359 days apart. The build date parameter anchors "today" so the wrap
// is testable at any run date.
// The day-of-year window wraps the year: plain ABS would make Dec 28 vs Jan
// 3 read as 359 days apart. The build date parameter anchors "today" so the
// wrap is testable at any run date.
pool := newPool(t)
q := dbq.New(pool)
u := seedUser(t, pool, "otd1")
+5 -4
View File
@@ -1,6 +1,6 @@
// Package playsessions implements the listening-session lifecycle: a session is a contiguous sequence of plays by a user with no
// gap longer than the configured timeout (default 30 minutes) between
// events.
// Package playsessions implements the listening-session lifecycle: a session is
// a contiguous sequence of plays by a user with no gap longer than the
// configured timeout (default 30 minutes) between events.
package playsessions
import (
@@ -34,7 +34,8 @@ func FindOrCreate(
return pgtype.UUID{}, err
}
if err == nil {
// "Within window" includes the exact-boundary case: gap <= timeout extends.
// "Within window" includes the exact-boundary case: gap <= timeout
// extends.
gap := eventTime.Sub(prev.LastEventAt.Time)
if gap <= timeout {
if err := q.TouchPlaySessionLastEvent(ctx, dbq.TouchPlaySessionLastEventParams{
+4 -3
View File
@@ -190,9 +190,10 @@ func LoadCandidatesFromSimilarity(
Limit_5: int32(limits.RandomFill),
Limit_6: int32(limits.TasteOverlap),
Limit_7: int32(limits.UserCoplay),
// The randomised arms order by md5(id || this) rather than random(),
// so the caller decides whether the draw repeats: a per-(user, day) seed for the system
// mixes that promise daily determinism, a fresh one per radio request.
// The randomised arms order by md5(id || this) rather than random(), so
// the caller decides whether the draw repeats: a per-(user, day) seed
// for the system mixes that promise daily determinism, a fresh one per
// radio request.
Column12: orderSeed,
})
if err != nil {
+3 -3
View File
@@ -181,9 +181,9 @@ var jobBoundary = regexp.MustCompile(`\n\n [^ \n]`)
//
// The naive cut — "up to the next `- name:`" — silently returns an EMPTY body
// for the last step in a job, and every assertion over it then passes
// vacuously: a check that reads as coverage while asserting on nothing. Cutting at a blank line followed by
// job-level indentation handles the last-step case, which is exactly where
// `Build and push` sits.
// vacuously: a check that reads as coverage while asserting on nothing. Cutting
// at a blank line followed by job-level indentation handles the last-step case,
// which is exactly where `Build and push` sits.
func stepBody(t *testing.T, yaml, name string) string {
t.Helper()
i := strings.Index(yaml, "- name: "+name)

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