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