DLNA renderers, app updates told like Librarian, acquisition without Lidarr, Collection and admin rework (M502–M505, M542) #156

Merged
bvandeusen merged 117 commits from dev into main 2026-10-26 12:32:33 -04:00
102 changed files with 518 additions and 522 deletions
Showing only changes of commit 08a2c06b85 - Show all commits
+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