docs: plan-era "v1", "Task N" and "Phase N" wording restated as facts (#6277)
release / govulncheck (push) Successful in 25s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m47s
release / integration (push) Successful in 4m35s
release / android (push) Successful in 7m5s
release / Build signed APK (releases and dev) (push) Successful in 7m16s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m1s
release / Verify release artifacts (tag releases only) (push) Skipped

The marker list behind the earlier server and web passes did not catch
"for v1", "Task 5", "Phase 2" or "today: Flutter". Those comments now say
what the code does.

Corrected: coverart said "both v1 providers" ship on. There are four
providers (MBCAA, TheAudioDB, Deezer, Last.fm), and Last.fm, which needs a
key, ships off.

Non-comment tokens unchanged against HEAD: 10 Go files, 7 TS/Svelte files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 13:05:03 -04:00
co-authored by Claude Opus 5.5
parent c96231106f
commit 466912e202
17 changed files with 31 additions and 32 deletions
+4 -5
View File
@@ -41,9 +41,8 @@ func TestLibrarySync_AfterArtistUpsert_ReturnsHydratedPayload(t *testing.T) {
truncateLibrary(t, pool) truncateLibrary(t, pool)
u := seedUser(t, pool, "alice", "x", false) u := seedUser(t, pool, "alice", "x", false)
// Seed an artist + write a change row in the same tx — that's the // Seed an artist + write a change row in the same tx — the invariant
// invariant the scanner wiring (Task 5) will enforce, and the // library writes keep, and the handler relies on.
// handler relies on.
ctx := context.Background() ctx := context.Background()
tx, err := pool.Begin(ctx) tx, err := pool.Begin(ctx)
if err != nil { if err != nil {
@@ -122,8 +121,8 @@ func TestLibrarySync_DeleteIsReflected(t *testing.T) {
u := seedUser(t, pool, "alice", "x", false) u := seedUser(t, pool, "alice", "x", false)
// Just write a delete change row directly (no underlying mutation). // Just write a delete change row directly (no underlying mutation).
// Scanner wiring (Task 5) is what couples real deletes to LogChange; // Library writes couple real deletes to LogChange; here we test the
// here we test the handler reflects what's in the log. // handler reflects what's in the log.
ctx := context.Background() ctx := context.Background()
tx, _ := pool.Begin(ctx) tx, _ := pool.Begin(ctx)
_ = sync.LogChange(ctx, tx, sync.EntityArtist, "deadbeef-dead-beef-dead-beefdeadbeef", sync.OpDelete) _ = sync.LogChange(ctx, tx, sync.EntityArtist, "deadbeef-dead-beef-dead-beefdeadbeef", sync.OpDelete)
+1 -1
View File
@@ -23,7 +23,7 @@ type updateProfileReq struct {
// nil = leave unchanged; non-nil empty string = clear; non-nil // nil = leave unchanged; non-nil empty string = clear; non-nil
// non-empty = update to that value. Note: distinguishing // non-empty = update to that value. Note: distinguishing
// omit-vs-clear at JSON-decode time requires custom unmarshaling // omit-vs-clear at JSON-decode time requires custom unmarshaling
// or a sentinel; for v1 we use the simpler "empty string clears" // or a sentinel; we use the simpler "empty string clears"
// semantics: any field present in the JSON body is applied. // semantics: any field present in the JSON body is applied.
DisplayName *string `json:"display_name"` DisplayName *string `json:"display_name"`
Email *string `json:"email"` Email *string `json:"email"`
+3 -3
View File
@@ -32,7 +32,7 @@ type Provider interface {
RequiresAPIKey() bool RequiresAPIKey() bool
// DefaultEnabled is whether a freshly-installed Minstrel ships // DefaultEnabled is whether a freshly-installed Minstrel ships
// with this provider on. Both v1 providers return true. // with this provider on. Providers that need an API key ship off.
DefaultEnabled() bool DefaultEnabled() bool
// Configure receives the admin's settings (api_key, enabled // Configure receives the admin's settings (api_key, enabled
@@ -102,8 +102,8 @@ type ArtistArtProvider interface {
// TestableProvider is an opt-in capability: providers that can answer // TestableProvider is an opt-in capability: providers that can answer
// "is my config working?" without performing a full enrichment cycle. // "is my config working?" without performing a full enrichment cycle.
// Used by the admin Test-Connection button. v1: both MBCAA and // Used by the admin Test-Connection button. MBCAA and TheAudioDB
// TheAudioDB implement this. // implement this.
type TestableProvider interface { type TestableProvider interface {
Provider Provider
TestConnection(ctx context.Context) error TestConnection(ctx context.Context) error
+2 -2
View File
@@ -23,8 +23,8 @@ const (
// theAudioDBMinPeriod enforces TheAudioDB's documented free/test // theAudioDBMinPeriod enforces TheAudioDB's documented free/test
// rate limit (2 req/sec). Patreon supporter keys allow higher // rate limit (2 req/sec). Patreon supporter keys allow higher
// rates; we don't differentiate in v1 — operators with supporter // rates; we don't differentiate — supporter keys get the same
// keys experience the conservative 2 req/sec ceiling. // conservative 2 req/sec ceiling.
theAudioDBMinPeriod = 500 * time.Millisecond theAudioDBMinPeriod = 500 * time.Millisecond
// theAudioDBMaxRetries is the number of retry attempts on 429 / // theAudioDBMaxRetries is the number of retry attempts on 429 /
+5 -4
View File
@@ -1,5 +1,6 @@
// Package eventbus is an in-process publish/subscribe bus for real-time // Package eventbus is an in-process publish/subscribe bus for real-time
// events the server emits to subscribed clients (today: Flutter via SSE). // events the server emits to subscribed clients (the web and Android apps,
// over SSE).
// //
// Writers (likes handler, requests reconciler, scanner, etc.) call Publish // Writers (likes handler, requests reconciler, scanner, etc.) call Publish
// to announce a state change. Each connected SSE client holds a // to announce a state change. Each connected SSE client holds a
@@ -13,9 +14,9 @@
// - No persistence. A client that connects after an event was emitted // - No persistence. A client that connects after an event was emitted
// will not see that event. SSE clients are expected to fetch a fresh // will not see that event. SSE clients are expected to fetch a fresh
// snapshot via the normal /api/* endpoints on (re)connect. // snapshot via the normal /api/* endpoints on (re)connect.
// - No cross-process broadcast. v1 runs as a single Go binary; a future // - No cross-process broadcast. Minstrel runs as a single Go binary; a
// multi-instance deployment will need a real broker (NATS / Redis // multi-instance deployment would need a real broker (NATS / Redis
// pub-sub / etc.) but that's out of scope here. // pub-sub / etc.).
package eventbus package eventbus
import ( import (
+2 -2
View File
@@ -74,8 +74,8 @@ func (s *SMTPSender) Send(ctx context.Context, to, subject, textBody, htmlBody s
// We respect the use_tls flag: if true, we require STARTTLS via // We respect the use_tls flag: if true, we require STARTTLS via
// smtp.SendMail's default behavior (which negotiates STARTTLS when // smtp.SendMail's default behavior (which negotiates STARTTLS when
// the server advertises it). For implicit-TLS (port 465 style), // the server advertises it). For implicit-TLS (port 465 style),
// we'd need a custom dial — skip that complexity for v1 and rely on // we'd need a custom dial; instead this relies on STARTTLS via port
// STARTTLS via port 587 (the default). // 587 (the default).
dialCtx, cancel := context.WithTimeout(ctx, 30*time.Second) dialCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel() defer cancel()
_ = dialCtx // smtp.SendMail doesn't accept ctx; the timeout is enforced via Dialer below _ = dialCtx // smtp.SendMail doesn't accept ctx; the timeout is enforced via Dialer below
+1 -1
View File
@@ -92,7 +92,7 @@ type CandidateSourceLimits struct {
UserCoplay int UserCoplay int
} }
// DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec. // DefaultCandidateSourceLimits returns the hardcoded per-source limits.
func DefaultCandidateSourceLimits() CandidateSourceLimits { func DefaultCandidateSourceLimits() CandidateSourceLimits {
return CandidateSourceLimits{ return CandidateSourceLimits{
LBSimilar: 30, LBSimilar: 30,
+1 -2
View File
@@ -1,8 +1,7 @@
package recommendation package recommendation
// SimilarityWeights balances the per-axis contribution to the weighted Jaccard // SimilarityWeights balances the per-axis contribution to the weighted Jaccard
// score. v1 hardcodes the defaults — operators cannot tune via YAML. If // score. The defaults are hardcoded; there is no config key for them.
// telemetry justifies it, expose under recommendation.similarity.* later.
type SimilarityWeights struct { type SimilarityWeights struct {
TagsWeight float64 TagsWeight float64
ArtistsWeight float64 ArtistsWeight float64
+2 -2
View File
@@ -48,8 +48,8 @@ func parseUUID(s string) (pgtype.UUID, bool) {
return u, u.Valid return u, u.Valid
} }
// MusicFolder matches Subsonic's /rest/getMusicFolders. v1 always reports a // MusicFolder matches Subsonic's /rest/getMusicFolders. Minstrel always
// single folder because Minstrel has no multi-library concept yet. // reports a single folder: it has no multi-library concept.
type MusicFolder struct { type MusicFolder struct {
XMLName xml.Name `json:"-" xml:"musicFolder"` XMLName xml.Name `json:"-" xml:"musicFolder"`
ID int `json:"id" xml:"id,attr"` ID int `json:"id" xml:"id,attr"`
+1 -1
View File
@@ -50,7 +50,7 @@ func TestRegisterDuplicatePanics(t *testing.T) {
Register(&fakeProvider{id: "dup"}) Register(&fakeProvider{id: "dup"})
} }
// TestBuiltinProvidersRegister confirms the two v1 sources register with // TestBuiltinProvidersRegister confirms the two built-in sources register with
// the expected identities/defaults. Runs against a fresh registry so it is // the expected identities/defaults. Runs against a fresh registry so it is
// independent of test ordering: re-register constructed instances. // independent of test ordering: re-register constructed instances.
func TestBuiltinProvidersRegister(t *testing.T) { func TestBuiltinProvidersRegister(t *testing.T) {
+2 -2
View File
@@ -7,8 +7,8 @@ export const BROWSE_PAGE_SIZE = 50;
// Genres are the raw ID3 strings, split on [;,] server-side but otherwise // Genres are the raw ID3 strings, split on [;,] server-side but otherwise
// untouched — no case folding, no synonym mapping. So "Rock" and "rock" can // untouched — no case folding, no synonym mapping. So "Rock" and "rock" can
// both appear, as can "Rock/Pop" beside "Rock" and "Pop". Deliberate for v1: // both appear, as can "Rock/Pop" beside "Rock" and "Pop". Deliberate: the
// the raw spread has to be visible before anyone can judge whether it needs // raw spread has to be visible before anyone can judge whether it needs
// normalising. // normalising.
export type GenreCount = { genre: string; track_count: number }; export type GenreCount = { genre: string; track_count: number };
+1 -1
View File
@@ -101,7 +101,7 @@
// The server-side filter hides this candidate on next refetch. // The server-side filter hides this candidate on next refetch.
await client.invalidateQueries({ queryKey: qk.suggestions() }); await client.invalidateQueries({ queryKey: qk.suggestions() });
} catch { } catch {
// Swallow for v1; the SPA will refetch on next mount and the card // Swallowed: the SPA will refetch on next mount and the card
// stays requestable so the user can retry. // stays requestable so the user can retry.
} }
} }
+1 -1
View File
@@ -133,7 +133,7 @@ export function useEventsDispatcher(): void {
openTrackId = trackId; openTrackId = trackId;
} }
} catch { } catch {
// Best-effort; missed events are OK in v1. // Best-effort; a missed event is acceptable.
} }
} }
} }
+1 -1
View File
@@ -79,7 +79,7 @@ describe('player store — basic actions', () => {
// play anyway — tested via report later. Here, simulate the paused state: // play anyway — tested via report later. Here, simulate the paused state:
reportTimeUpdate(0); reportTimeUpdate(0);
// manually put us in paused state by calling togglePlay twice isn't clean; // manually put us in paused state by calling togglePlay twice isn't clean;
// set via the state-report API in Task 2. Instead, test the simpler case: // it is set via the state-report API. Instead, test the simpler case:
// playQueue then togglePlay = still 'loading' (intent is to play). // playQueue then togglePlay = still 'loading' (intent is to play).
togglePlay(); // from 'loading' — inverts intent; store moves to 'paused' intent togglePlay(); // from 'loading' — inverts intent; store moves to 'paused' intent
expect(player.state).toBe('paused'); expect(player.state).toBe('paused');
+1 -1
View File
@@ -77,7 +77,7 @@
} }
}); });
// Phase 2 prefetch: when the current track passes the halfway mark, // Prefetch: when the current track passes the halfway mark,
// start loading the next track's bytes into a hidden <audio> element. // start loading the next track's bytes into a hidden <audio> element.
// The browser HTTP cache + audio buffer warm-up means the swap on // The browser HTTP cache + audio buffer warm-up means the swap on
// track-end is gap-free on slow networks. Reset src when no next // track-end is gap-free on slow networks. Reset src when no next
+2 -2
View File
@@ -94,8 +94,8 @@
// Surface the new request on /requests right away (+ start its poll). // Surface the new request on /requests right away (+ start its poll).
queryClient.invalidateQueries({ queryKey: qk.myRequests() }); queryClient.invalidateQueries({ queryKey: qk.myRequests() });
} catch { } catch {
// Swallow for v1: error toasts land in a later UX pass. The card stays // Swallowed, with no toast: the card stays in 'requestable' so the
// in 'requestable' so the user can retry. // user can retry.
} }
} }
+1 -1
View File
@@ -28,7 +28,7 @@
await unflagTrack(trackID); await unflagTrack(trackID);
await client.invalidateQueries({ queryKey: qk.myQuarantine() }); await client.invalidateQueries({ queryKey: qk.myQuarantine() });
} catch { } catch {
// Silent in v1; the SPA will refetch on next mount. // Silent; the SPA will refetch on next mount.
} }
} }
</script> </script>