diff --git a/testing/e2e/add_artist_test.go b/testing/e2e/add_artist_test.go new file mode 100644 index 0000000..058a482 --- /dev/null +++ b/testing/e2e/add_artist_test.go @@ -0,0 +1,600 @@ +// Package e2e contains end-to-end tests for the music aggregator. +// +// Prerequisites: +// - PostgreSQL running (docker-compose up -d postgres) +// - MetadataService gRPC server running +// - API server running (go run ./cmd/server) +// +// Run tests: +// +// go test -v ./testing/e2e/... -tags=e2e +package e2e + +import ( + "context" + "testing" + "time" + + "github.com/fujin/music-agregator/testing/e2e/testutil" +) + +// TestAddArtist_Flow covers section 1.1 of FLOWS.md: +// 1. User searches for artist by name +// 2. MetadataService returns matching artists +// 3. User selects artist (picks quality profile, root folder, monitoring options) +// 4. System fetches all albums from MetadataService +// 5. Persists artist_metadata, artists (library entry), albums to DB +// 6. Albums marked monitored/unmonitored based on monitoring preset +// 7. If "search on add": triggers search for all monitored albums +func TestAddArtist_Flow(t *testing.T) { + env := testutil.NewTestEnv(t) + defer env.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + t.Run("Step1_SearchArtistByName", func(t *testing.T) { + testSearchArtistByName(t, env, ctx) + }) + + t.Run("Step2_MetadataServiceReturnsArtists", func(t *testing.T) { + testMetadataServiceReturnsArtists(t, env, ctx) + }) + + t.Run("Step3_GetArtistAlbums", func(t *testing.T) { + testGetArtistAlbums(t, env, ctx) + }) + + t.Run("Step4_SyncPersistsArtistAndAlbums", func(t *testing.T) { + testSyncPersistsArtistAndAlbums(t, env, ctx) + }) + + t.Run("Step5_SyncWithDownloadOption", func(t *testing.T) { + testSyncWithDownloadOption(t, env, ctx) + }) +} + +func testSearchArtistByName(t *testing.T, env *testutil.TestEnv, ctx context.Context) { + resp, err := env.POST("/api/metadata/artists/search", map[string]any{ + "query": "Radiohead", + "limit": 5, + }) + if err != nil { + t.Fatalf("search request failed: %v", err) + } + + resp.AssertStatus(t, 200) + + var result struct { + Artists []struct { + ID string `json:"id"` + Name string `json:"name"` + ArtistType string `json:"artistType"` + } `json:"artists"` + Total int `json:"total"` + } + if err := resp.DecodeJSON(&result); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(result.Artists) == 0 { + t.Fatal("expected at least one artist in search results") + } + + found := false + for _, artist := range result.Artists { + if artist.Name == "Radiohead" { + found = true + if artist.ID == "" { + t.Error("artist ID should not be empty") + } + break + } + } + if !found { + t.Errorf("expected to find 'Radiohead' in results, got: %+v", result.Artists) + } +} + +func testMetadataServiceReturnsArtists(t *testing.T, env *testutil.TestEnv, ctx context.Context) { + resp, err := env.POST("/api/metadata/artists/search", map[string]any{ + "query": "The Beatles", + "limit": 10, + "offset": 0, + }) + if err != nil { + t.Fatalf("search request failed: %v", err) + } + + resp.AssertStatus(t, 200) + + var result struct { + Artists []struct { + ID string `json:"id"` + Name string `json:"name"` + SortName string `json:"sortName"` + ArtistType string `json:"artistType"` + Country string `json:"country"` + Description string `json:"description"` + Genres []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"genres"` + ExternalIds []struct { + Source string `json:"source"` + SourceId string `json:"sourceId"` + } `json:"externalIds"` + } `json:"artists"` + Total int `json:"total"` + } + if err := resp.DecodeJSON(&result); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if result.Total == 0 { + t.Fatal("expected total > 0") + } + + if len(result.Artists) == 0 { + t.Fatal("expected artists in response") + } + + artist := result.Artists[0] + if artist.ID == "" { + t.Error("artist ID should not be empty") + } + if artist.Name == "" { + t.Error("artist name should not be empty") + } +} + +func testGetArtistAlbums(t *testing.T, env *testutil.TestEnv, ctx context.Context) { + searchResp, err := env.POST("/api/metadata/artists/search", map[string]any{ + "query": "Pink Floyd", + "limit": 1, + }) + if err != nil { + t.Fatalf("search request failed: %v", err) + } + searchResp.AssertStatus(t, 200) + + var searchResult struct { + Artists []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"artists"` + } + if err := searchResp.DecodeJSON(&searchResult); err != nil { + t.Fatalf("failed to decode search response: %v", err) + } + + if len(searchResult.Artists) == 0 { + t.Skip("Pink Floyd not found in metadata service") + } + + artistID := searchResult.Artists[0].ID + + albumsResp, err := env.GET("/api/metadata/artists/" + artistID + "/albums") + if err != nil { + t.Fatalf("get albums request failed: %v", err) + } + albumsResp.AssertStatus(t, 200) + + var albumsResult struct { + Albums []struct { + ID string `json:"id"` + Title string `json:"title"` + AlbumType string `json:"albumType"` + ReleaseDate string `json:"releaseDate"` + TotalTracks int `json:"totalTracks"` + } `json:"albums"` + Total int `json:"total"` + } + if err := albumsResp.DecodeJSON(&albumsResult); err != nil { + t.Fatalf("failed to decode albums response: %v", err) + } + + if len(albumsResult.Albums) == 0 { + t.Fatal("expected at least one album") + } + + album := albumsResult.Albums[0] + if album.ID == "" { + t.Error("album ID should not be empty") + } + if album.Title == "" { + t.Error("album title should not be empty") + } +} + +func testSyncPersistsArtistAndAlbums(t *testing.T, env *testutil.TestEnv, ctx context.Context) { + artistName := "Nirvana" + + if err := env.CleanupArtistByName(ctx, artistName); err != nil { + t.Fatalf("cleanup failed: %v", err) + } + + initialArtistCount, _ := env.CountArtists(ctx) + initialAlbumCount, _ := env.CountAlbums(ctx) + + syncResp, err := env.POST("/api/sync", map[string]any{ + "artist": artistName, + "store": true, + "download": false, + }) + if err != nil { + t.Fatalf("sync request failed: %v", err) + } + syncResp.AssertStatus(t, 200) + + var syncResult struct { + ArtistID string `json:"artist_id"` + ArtistName string `json:"artist_name"` + TotalAlbums int `json:"total_albums"` + AlbumsStored int `json:"albums_stored"` + Results []struct { + AlbumID string `json:"album_id"` + AlbumTitle string `json:"album_title"` + Stored bool `json:"stored"` + } `json:"results"` + } + if err := syncResp.DecodeJSON(&syncResult); err != nil { + t.Fatalf("failed to decode sync response: %v", err) + } + + if syncResult.ArtistID == "" { + t.Error("artist_id should not be empty") + } + if syncResult.ArtistName != artistName { + t.Errorf("expected artist_name=%q, got %q", artistName, syncResult.ArtistName) + } + if syncResult.TotalAlbums == 0 { + t.Error("expected total_albums > 0") + } + if syncResult.AlbumsStored == 0 { + t.Error("expected albums_stored > 0") + } + + finalArtistCount, _ := env.CountArtists(ctx) + finalAlbumCount, _ := env.CountAlbums(ctx) + + if finalArtistCount <= initialArtistCount { + t.Errorf("expected artist count to increase, was %d, now %d", initialArtistCount, finalArtistCount) + } + if finalAlbumCount <= initialAlbumCount { + t.Errorf("expected album count to increase, was %d, now %d", initialAlbumCount, finalAlbumCount) + } + + artist, err := env.GetArtistByForeignID(ctx, syncResult.ArtistID) + if err != nil { + t.Fatalf("failed to get artist from DB: %v", err) + } + + if artist["name"] != artistName { + t.Errorf("expected artist name=%q in DB, got %q", artistName, artist["name"]) + } + + albums, err := env.GetAlbumsByArtistForeignID(ctx, syncResult.ArtistID) + if err != nil { + t.Fatalf("failed to get albums from DB: %v", err) + } + + if len(albums) != syncResult.AlbumsStored { + t.Errorf("expected %d albums in DB, got %d", syncResult.AlbumsStored, len(albums)) + } + + t.Cleanup(func() { + env.CleanupArtistByName(context.Background(), artistName) + }) +} + +func testSyncWithDownloadOption(t *testing.T, env *testutil.TestEnv, ctx context.Context) { + artistName := "Portishead" + + if err := env.CleanupArtistByName(ctx, artistName); err != nil { + t.Fatalf("cleanup failed: %v", err) + } + + syncResp, err := env.POST("/api/sync", map[string]any{ + "artist": artistName, + "album": "Dummy", + "store": true, + "download": true, + }) + if err != nil { + t.Fatalf("sync request failed: %v", err) + } + + if syncResp.StatusCode != 200 { + t.Logf("sync with download returned status %d (may be expected if torrent client unavailable): %s", + syncResp.StatusCode, string(syncResp.Body)) + } + + var syncResult struct { + ArtistID string `json:"artist_id"` + ArtistName string `json:"artist_name"` + TotalAlbums int `json:"total_albums"` + AlbumsStored int `json:"albums_stored"` + AlbumsDownloaded int `json:"albums_downloaded"` + AlbumsNoResults int `json:"albums_no_results"` + AlbumsFailed int `json:"albums_failed"` + Results []struct { + AlbumID string `json:"album_id"` + AlbumTitle string `json:"album_title"` + Stored bool `json:"stored"` + DownloadStatus *string `json:"download_status"` + TorrentHash *string `json:"torrent_hash"` + Indexer *string `json:"indexer"` + Error *string `json:"error"` + } `json:"results"` + } + if err := syncResp.DecodeJSON(&syncResult); err != nil { + t.Fatalf("failed to decode sync response: %v", err) + } + + if syncResult.ArtistName != artistName { + t.Errorf("expected artist_name=%q, got %q", artistName, syncResult.ArtistName) + } + + for _, result := range syncResult.Results { + if result.DownloadStatus == nil { + t.Errorf("expected download_status for album %q", result.AlbumTitle) + continue + } + + status := *result.DownloadStatus + switch status { + case "added": + if result.Indexer == nil || *result.Indexer == "" { + t.Errorf("expected indexer for downloaded album %q", result.AlbumTitle) + } + case "noresults": + t.Logf("no results for album %q (expected if indexers have no matches)", result.AlbumTitle) + case "failed": + t.Logf("download failed for album %q: %v", result.AlbumTitle, result.Error) + } + } + + t.Cleanup(func() { + env.CleanupArtistByName(context.Background(), artistName) + }) +} + +func TestAddArtist_SearchNotFound(t *testing.T) { + env := testutil.NewTestEnv(t) + defer env.Close() + + resp, err := env.POST("/api/sync", map[string]any{ + "artist": "ThisArtistDefinitelyDoesNotExist12345XYZ", + "store": true, + "download": false, + }) + if err != nil { + t.Fatalf("request failed: %v", err) + } + + resp.AssertStatus(t, 404) + + var errorResp struct { + Error string `json:"error"` + } + if err := resp.DecodeJSON(&errorResp); err != nil { + t.Fatalf("failed to decode error response: %v", err) + } + + if errorResp.Error == "" { + t.Error("expected error message in response") + } +} + +func TestAddArtist_FilterByAlbum(t *testing.T) { + env := testutil.NewTestEnv(t) + defer env.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + artistName := "Massive Attack" + albumFilter := "Mezzanine" + + if err := env.CleanupArtistByName(ctx, artistName); err != nil { + t.Fatalf("cleanup failed: %v", err) + } + + syncResp, err := env.POST("/api/sync", map[string]any{ + "artist": artistName, + "album": albumFilter, + "store": true, + "download": false, + }) + if err != nil { + t.Fatalf("sync request failed: %v", err) + } + syncResp.AssertStatus(t, 200) + + var syncResult struct { + TotalAlbums int `json:"total_albums"` + AlbumsStored int `json:"albums_stored"` + Results []struct { + AlbumTitle string `json:"album_title"` + } `json:"results"` + } + if err := syncResp.DecodeJSON(&syncResult); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if syncResult.TotalAlbums == 0 { + t.Skip("no albums matched filter (metadata service may not have this album)") + } + + for _, result := range syncResult.Results { + if result.AlbumTitle == "" { + continue + } + t.Logf("stored album: %s", result.AlbumTitle) + } + + t.Cleanup(func() { + env.CleanupArtistByName(context.Background(), artistName) + }) +} + +func TestAddArtist_LibraryListsAfterSync(t *testing.T) { + env := testutil.NewTestEnv(t) + defer env.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + searchQuery := "Bjork" + + if err := env.CleanupArtistByName(ctx, "%jork%"); err != nil { + t.Fatalf("cleanup failed: %v", err) + } + + syncResp, err := env.POST("/api/sync", map[string]any{ + "artist": searchQuery, + "store": true, + "download": false, + }) + if err != nil { + t.Fatalf("sync request failed: %v", err) + } + syncResp.AssertStatus(t, 200) + + var syncResult struct { + ArtistName string `json:"artist_name"` + } + if err := syncResp.DecodeJSON(&syncResult); err != nil { + t.Fatalf("failed to decode sync response: %v", err) + } + + actualArtistName := syncResult.ArtistName + + artistsResp, err := env.GET("/api/library/artists") + if err != nil { + t.Fatalf("list artists request failed: %v", err) + } + artistsResp.AssertStatus(t, 200) + + var artists []struct { + ID string `json:"id"` + Name string `json:"name"` + } + if err := artistsResp.DecodeJSON(&artists); err != nil { + t.Fatalf("failed to decode artists: %v", err) + } + + found := false + for _, a := range artists { + if a.Name == actualArtistName { + found = true + break + } + } + if !found { + t.Errorf("expected %q in library artists list", actualArtistName) + } + + albumsResp, err := env.GET("/api/library/albums") + if err != nil { + t.Fatalf("list albums request failed: %v", err) + } + albumsResp.AssertStatus(t, 200) + + var albums []struct { + Title string `json:"title"` + ArtistName string `json:"artist_name"` + } + if err := albumsResp.DecodeJSON(&albums); err != nil { + t.Fatalf("failed to decode albums: %v", err) + } + + foundAlbum := false + for _, a := range albums { + if a.ArtistName == actualArtistName { + foundAlbum = true + break + } + } + if !foundAlbum { + t.Errorf("expected albums by %q in library albums list", actualArtistName) + } + + statsResp, err := env.GET("/api/library/stats") + if err != nil { + t.Fatalf("stats request failed: %v", err) + } + statsResp.AssertStatus(t, 200) + + var stats struct { + Artists int64 `json:"artists"` + Albums int64 `json:"albums"` + } + if err := statsResp.DecodeJSON(&stats); err != nil { + t.Fatalf("failed to decode stats: %v", err) + } + + if stats.Artists == 0 { + t.Error("expected artists > 0 in stats") + } + if stats.Albums == 0 { + t.Error("expected albums > 0 in stats") + } + + t.Cleanup(func() { + env.CleanupArtistByName(context.Background(), "%jork%") + }) +} + +func TestAddArtist_IdempotentSync(t *testing.T) { + env := testutil.NewTestEnv(t) + defer env.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + artistName := "Aphex Twin" + + if err := env.CleanupArtistByName(ctx, artistName); err != nil { + t.Fatalf("cleanup failed: %v", err) + } + + firstResp, err := env.POST("/api/sync", map[string]any{ + "artist": artistName, + "store": true, + "download": false, + }) + if err != nil { + t.Fatalf("first sync request failed: %v", err) + } + firstResp.AssertStatus(t, 200) + + var firstResult struct { + AlbumsStored int `json:"albums_stored"` + } + firstResp.DecodeJSON(&firstResult) + + countAfterFirst, _ := env.CountAlbums(ctx) + + secondResp, err := env.POST("/api/sync", map[string]any{ + "artist": artistName, + "store": true, + "download": false, + }) + if err != nil { + t.Fatalf("second sync request failed: %v", err) + } + secondResp.AssertStatus(t, 200) + + countAfterSecond, _ := env.CountAlbums(ctx) + + if countAfterSecond != countAfterFirst { + t.Errorf("expected idempotent sync: album count was %d after first, %d after second", + countAfterFirst, countAfterSecond) + } + + t.Cleanup(func() { + env.CleanupArtistByName(context.Background(), artistName) + }) +} diff --git a/testing/e2e/testutil/testutil.go b/testing/e2e/testutil/testutil.go new file mode 100644 index 0000000..2108a08 --- /dev/null +++ b/testing/e2e/testutil/testutil.go @@ -0,0 +1,286 @@ +// Package testutil provides utilities for e2e tests. +// Tests require running services: PostgreSQL, MetadataService (gRPC), and the API server. +package testutil + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Config holds test environment configuration. +type Config struct { + APIBaseURL string + DatabaseURL string +} + +// DefaultConfig returns configuration from environment variables or defaults. +func DefaultConfig() Config { + return Config{ + APIBaseURL: getEnv("TEST_API_URL", "http://localhost:3000"), + DatabaseURL: getEnv("TEST_DATABASE_URL", "postgresql://music:music@localhost:5433/music_aggregator"), + } +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// TestEnv provides test environment with database and HTTP client. +type TestEnv struct { + Config Config + DB *pgxpool.Pool + Client *http.Client + t *testing.T +} + +// NewTestEnv creates a new test environment. +// It connects to the database and verifies the API is reachable. +func NewTestEnv(t *testing.T) *TestEnv { + t.Helper() + + cfg := DefaultConfig() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + pool, err := pgxpool.New(ctx, cfg.DatabaseURL) + if err != nil { + t.Fatalf("failed to connect to database: %v", err) + } + + if err := pool.Ping(ctx); err != nil { + t.Fatalf("failed to ping database: %v", err) + } + + client := &http.Client{Timeout: 30 * time.Second} + + env := &TestEnv{ + Config: cfg, + DB: pool, + Client: client, + t: t, + } + + if err := env.waitForAPI(ctx); err != nil { + t.Fatalf("API not reachable: %v", err) + } + + return env +} + +// Close cleans up test environment resources. +func (e *TestEnv) Close() { + if e.DB != nil { + e.DB.Close() + } +} + +// waitForAPI waits for the API to become available. +func (e *TestEnv) waitForAPI(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + return fmt.Errorf("timeout waiting for API") + default: + resp, err := e.Client.Get(e.Config.APIBaseURL + "/health") + if err == nil && resp.StatusCode == http.StatusOK { + resp.Body.Close() + return nil + } + if resp != nil { + resp.Body.Close() + } + time.Sleep(500 * time.Millisecond) + } + } +} + +// CleanupArtist removes an artist and all related data from the database. +func (e *TestEnv) CleanupArtist(ctx context.Context, foreignArtistID string) error { + _, err := e.DB.Exec(ctx, ` + DELETE FROM artist_metadata WHERE foreign_artist_id = $1 + `, foreignArtistID) + return err +} + +// CleanupArtistByName removes artists matching a name pattern. +func (e *TestEnv) CleanupArtistByName(ctx context.Context, namePattern string) error { + _, err := e.DB.Exec(ctx, ` + DELETE FROM artist_metadata WHERE name ILIKE $1 + `, namePattern) + return err +} + +// APIRequest makes an HTTP request to the API. +type APIRequest struct { + Method string + Path string + Body any +} + +// APIResponse holds the response from an API call. +type APIResponse struct { + StatusCode int + Body []byte +} + +// Do executes an API request and returns the response. +func (e *TestEnv) Do(req APIRequest) (*APIResponse, error) { + var bodyReader io.Reader + if req.Body != nil { + bodyBytes, err := json.Marshal(req.Body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + bodyReader = bytes.NewReader(bodyBytes) + } + + httpReq, err := http.NewRequest(req.Method, e.Config.APIBaseURL+req.Path, bodyReader) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + if req.Body != nil { + httpReq.Header.Set("Content-Type", "application/json") + } + + resp, err := e.Client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + return &APIResponse{ + StatusCode: resp.StatusCode, + Body: body, + }, nil +} + +// POST is a convenience method for POST requests. +func (e *TestEnv) POST(path string, body any) (*APIResponse, error) { + return e.Do(APIRequest{Method: http.MethodPost, Path: path, Body: body}) +} + +// GET is a convenience method for GET requests. +func (e *TestEnv) GET(path string) (*APIResponse, error) { + return e.Do(APIRequest{Method: http.MethodGet, Path: path}) +} + +// DecodeJSON decodes the response body into the given value. +func (r *APIResponse) DecodeJSON(v any) error { + return json.Unmarshal(r.Body, v) +} + +// AssertStatus checks that the response has the expected status code. +func (r *APIResponse) AssertStatus(t *testing.T, expected int) { + t.Helper() + if r.StatusCode != expected { + t.Errorf("expected status %d, got %d. Body: %s", expected, r.StatusCode, string(r.Body)) + } +} + +// CountArtists returns the number of artists in the database. +func (e *TestEnv) CountArtists(ctx context.Context) (int64, error) { + var count int64 + err := e.DB.QueryRow(ctx, "SELECT COUNT(*) FROM artist_metadata").Scan(&count) + return count, err +} + +// CountAlbums returns the number of albums in the database. +func (e *TestEnv) CountAlbums(ctx context.Context) (int64, error) { + var count int64 + err := e.DB.QueryRow(ctx, "SELECT COUNT(*) FROM albums").Scan(&count) + return count, err +} + +// GetArtistByForeignID retrieves an artist by foreign artist ID. +func (e *TestEnv) GetArtistByForeignID(ctx context.Context, foreignID string) (map[string]any, error) { + var result map[string]any + row := e.DB.QueryRow(ctx, ` + SELECT id, foreign_artist_id, name, sort_name, artist_type, genres + FROM artist_metadata + WHERE foreign_artist_id = $1 + `, foreignID) + + var id, foreignArtistID, name string + var sortName, artistType *string + var genres []byte + + err := row.Scan(&id, &foreignArtistID, &name, &sortName, &artistType, &genres) + if err != nil { + return nil, err + } + + result = map[string]any{ + "id": id, + "foreign_artist_id": foreignArtistID, + "name": name, + "sort_name": sortName, + "artist_type": artistType, + } + + var genreList []map[string]any + if err := json.Unmarshal(genres, &genreList); err == nil { + result["genres"] = genreList + } + + return result, nil +} + +// GetAlbumsByArtistForeignID retrieves albums for an artist by foreign artist ID. +func (e *TestEnv) GetAlbumsByArtistForeignID(ctx context.Context, foreignArtistID string) ([]map[string]any, error) { + rows, err := e.DB.Query(ctx, ` + SELECT a.id, a.foreign_album_id, a.title, a.album_type, a.release_date, a.monitored + FROM albums a + JOIN artist_metadata am ON a.artist_metadata_id = am.id + WHERE am.foreign_artist_id = $1 + ORDER BY a.release_date DESC NULLS LAST + `, foreignArtistID) + if err != nil { + return nil, err + } + defer rows.Close() + + var albums []map[string]any + for rows.Next() { + var id, title string + var foreignAlbumID, albumType *string + var releaseDate *time.Time + var monitored bool + + if err := rows.Scan(&id, &foreignAlbumID, &title, &albumType, &releaseDate, &monitored); err != nil { + return nil, err + } + + album := map[string]any{ + "id": id, + "foreign_album_id": foreignAlbumID, + "title": title, + "album_type": albumType, + "monitored": monitored, + } + if releaseDate != nil { + album["release_date"] = releaseDate.Format("2006-01-02") + } + albums = append(albums, album) + } + + return albums, nil +}