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

Merged
bvandeusen merged 117 commits from dev into main 2026-10-26 12:32:33 -04:00
102 changed files with 518 additions and 522 deletions
Showing only changes of commit 08a2c06b85 - Show all commits
+2 -2
View File
@@ -39,8 +39,8 @@ const (
// minInterval keeps to AcoustID's 3 requests a second with headroom: one // 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