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