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

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

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

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