diff --git a/docs/plans/2026-07-19-notifier-webui-sync.md b/docs/plans/2026-07-19-notifier-webui-sync.md index dc3dad0..055eab7 100644 --- a/docs/plans/2026-07-19-notifier-webui-sync.md +++ b/docs/plans/2026-07-19-notifier-webui-sync.md @@ -89,10 +89,10 @@ name collisions.) - [x] run tests - must pass before task 2 ### Task 2: MusicBrainz artist-ID resolver -- [ ] add `ResolveArtistMBID(ctx, client, name) (string, error)` in `internal/musicbrainz` using `/ws/2/artist/?query=artist:&fmt=json` -- [ ] parse first matching artist ID from JSON response; return error if none -- [ ] write tests with httptest stub (match found, no match, HTTP error) -- [ ] run tests - must pass before task 3 +- [x] add `ResolveArtistMBID(ctx, client, name) (string, error)` in `internal/musicbrainz` using `/ws/2/artist/?query=artist:&fmt=json` +- [x] parse first matching artist ID from JSON response; return error if none +- [x] write tests with httptest stub (match found, no match, HTTP error) +- [x] run tests - must pass before task 3 ### Task 3: Periodic sync pipeline - [ ] add `SyncAll(ctx, ndClient, mbClient, db, ttl)` orchestrator: for each monitored artist → ensure MBID (resolve + persist if missing) → `musicbrainz.SyncArtistDiscography` → `navidrome.SyncAlbums` diff --git a/internal/musicbrainz/resolve.go b/internal/musicbrainz/resolve.go new file mode 100644 index 0000000..448e9fc --- /dev/null +++ b/internal/musicbrainz/resolve.go @@ -0,0 +1,47 @@ +package musicbrainz + +import ( + "context" + "encoding/json" + "fmt" + "net/url" +) + +// mbArtistSearchResult models the JSON response of the MusicBrainz artist +// search endpoint (/ws/2/artist?query=artist:&fmt=json). Only the +// fields we need for MBID resolution are decoded. +type mbArtistSearchResult struct { + Artists []struct { + ID string `json:"id"` + Name string `json:"name"` + Score int `json:"score"` + } `json:"artists"` +} + +// ResolveArtistMBID resolves a MusicBrainz artist ID (MBID) for the given +// artist name by querying the MusicBrainz artist search endpoint. It returns +// the ID of the first (best-scoring) matching artist. An error is returned if +// the search yields no matches, the response cannot be parsed, or the +// underlying request fails. +func (c *MusicBrainzClient) ResolveArtistMBID(ctx context.Context, name string) (string, error) { + params := url.Values{} + params.Set("query", fmt.Sprintf("artist:%s", name)) + params.Set("fmt", "json") + path := "/artist?" + params.Encode() + + body, err := c.doGet(ctx, path) + if err != nil { + return "", fmt.Errorf("resolve MBID for artist %q: %w", name, err) + } + + var result mbArtistSearchResult + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("parse artist search response for %q: %w", name, err) + } + + if len(result.Artists) == 0 { + return "", fmt.Errorf("no MusicBrainz artist found for %q", name) + } + + return result.Artists[0].ID, nil +} diff --git a/internal/musicbrainz/resolve_test.go b/internal/musicbrainz/resolve_test.go new file mode 100644 index 0000000..2d350c9 --- /dev/null +++ b/internal/musicbrainz/resolve_test.go @@ -0,0 +1,105 @@ +package musicbrainz + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// newTestClient is defined in sync_test.go (signature: func newTestClient(serverURL string) *MusicBrainzClient). +// contains is defined in model_test.go (signature: func contains(s []string, want string) bool). + +func TestResolveArtistMBID_MatchFound(t *testing.T) { + var gotPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.RequestURI() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(mbArtistSearchResult{ + Artists: []struct { + ID string `json:"id"` + Name string `json:"name"` + Score int `json:"score"` + }{ + {ID: "f27a7a7e-5a47-4cd5-afbe-6b7b01672b3b", Name: "Radiohead", Score: 100}, + {ID: "another-id", Name: "Radiohead (Tribute)", Score: 80}, + }, + }) + })) + defer server.Close() + + client := newTestClient(server.URL) + + mbid, err := client.ResolveArtistMBID(context.Background(), "Radiohead") + if err != nil { + t.Fatalf("ResolveArtistMBID() error = %v", err) + } + + want := "f27a7a7e-5a47-4cd5-afbe-6b7b01672b3b" + if mbid != want { + t.Errorf("ResolveArtistMBID() = %q, want %q", mbid, want) + } + + // Verify the request used the expected query path and JSON format. + if gotPath == "" || !strings.Contains(gotPath, "/artist?") { + t.Errorf("ResolveArtistMBID() requested path = %q, want /artist?...", gotPath) + } + if !strings.Contains(gotPath, "query=artist%3ARadiohead") { + t.Errorf("ResolveArtistMBID() query missing artist name, got %q", gotPath) + } + if !strings.Contains(gotPath, "fmt=json") { + t.Errorf("ResolveArtistMBID() query missing fmt=json, got %q", gotPath) + } + +} + +func TestResolveArtistMBID_NoMatch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(mbArtistSearchResult{Artists: []struct { + ID string `json:"id"` + Name string `json:"name"` + Score int `json:"score"` + }{}}) + })) + defer server.Close() + + client := newTestClient(server.URL) + + _, err := client.ResolveArtistMBID(context.Background(), "Nonexistent Artist 12345") + if err == nil { + t.Fatal("ResolveArtistMBID() expected error for no match, got nil") + } +} + +func TestResolveArtistMBID_HTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte("Service Unavailable")) + })) + defer server.Close() + + client := newTestClient(server.URL) + + _, err := client.ResolveArtistMBID(context.Background(), "Radiohead") + if err == nil { + t.Fatal("ResolveArtistMBID() expected error on HTTP failure, got nil") + } +} + +func TestResolveArtistMBID_InvalidJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`not valid json`)) + })) + defer server.Close() + + client := newTestClient(server.URL) + + _, err := client.ResolveArtistMBID(context.Background(), "Radiohead") + if err == nil { + t.Fatal("ResolveArtistMBID() expected parse error, got nil") + } +}