diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 7766068..a0659ca 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -323,6 +323,54 @@ func (h *Handlers) DeleteArtist(w http.ResponseWriter, r *http.Request) { }) } +func (h *Handlers) GetArtist(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + artistID := chi.URLParam(r, "id") + if artistID == "" { + writeError(w, http.StatusBadRequest, "artist ID required") + return + } + + artist, err := h.DB.GetArtistByForeignID(r.Context(), artistID) + if err != nil { + writeError(w, http.StatusNotFound, "artist not found: "+artistID) + return + } + + writeJSON(w, http.StatusOK, artist) +} + +func (h *Handlers) EditArtist(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + artistID := chi.URLParam(r, "id") + if artistID == "" { + writeError(w, http.StatusBadRequest, "artist ID required") + return + } + + var update database.ArtistUpdate + if err := json.NewDecoder(r.Body).Decode(&update); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + artist, err := h.DB.UpdateArtistByForeignID(r.Context(), artistID, update) + if err != nil { + writeError(w, http.StatusNotFound, "artist not found: "+artistID) + return + } + + writeJSON(w, http.StatusOK, artist) +} + func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/internal/api/router.go b/internal/api/router.go index f9c204b..ca3651a 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -44,6 +44,8 @@ func NewRouter(h *Handlers) *chi.Mux { r.Post("/sync", h.Sync) r.Route("/artists", func(r chi.Router) { + r.Get("/{id}", h.GetArtist) + r.Put("/{id}", h.EditArtist) r.Post("/{id}/refresh", h.RefreshArtist) r.Delete("/{id}", h.DeleteArtist) }) diff --git a/internal/database/db.go b/internal/database/db.go index 1731900..810f4d7 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -251,19 +251,6 @@ func (db *DB) CountAlbums(ctx context.Context) (int64, error) { return count, err } -func (db *DB) GetArtistByForeignID(ctx context.Context, foreignArtistID string) (*ArtistMetadataRow, error) { - var a ArtistMetadataRow - err := db.pool.QueryRow(ctx, ` - SELECT id, foreign_artist_id, name, sort_name, artist_type, genres, created_at, updated_at - FROM artist_metadata - WHERE foreign_artist_id = $1 - `, foreignArtistID).Scan(&a.ID, &a.ForeignArtistID, &a.Name, &a.SortName, &a.ArtistType, &a.Genres, &a.CreatedAt, &a.UpdatedAt) - if err != nil { - return nil, err - } - return &a, nil -} - func (db *DB) CountAlbumsByArtist(ctx context.Context, artistMetadataID uuid.UUID) (int64, error) { var count int64 err := db.pool.QueryRow(ctx, ` @@ -288,3 +275,144 @@ func (db *DB) DeleteArtistByForeignID(ctx context.Context, foreignArtistID strin } return result.RowsAffected() > 0, nil } + +type ArtistRow struct { + ID uuid.UUID `json:"id"` + MetadataID uuid.UUID `json:"metadata_id"` + ForeignArtistID string `json:"foreign_artist_id"` + Name string `json:"name"` + QualityProfileID *uuid.UUID `json:"quality_profile_id"` + MetadataProfileID *uuid.UUID `json:"metadata_profile_id"` + RootFolderID *uuid.UUID `json:"root_folder_id"` + Path *string `json:"path"` + Monitored bool `json:"monitored"` + MonitorNewItems string `json:"monitor_new_items"` +} + +func (db *DB) UpsertArtist(ctx context.Context, metadataID uuid.UUID) (uuid.UUID, error) { + var existingID uuid.UUID + err := db.pool.QueryRow(ctx, ` + SELECT id FROM artists WHERE metadata_id = $1 + `, metadataID).Scan(&existingID) + if err == nil { + return existingID, nil + } + + var resultID uuid.UUID + err = db.pool.QueryRow(ctx, ` + INSERT INTO artists (metadata_id, monitored, monitor_new_items) + VALUES ($1, true, 'all') + RETURNING id + `, metadataID).Scan(&resultID) + return resultID, err +} + +func (db *DB) GetArtistByForeignID(ctx context.Context, foreignArtistID string) (*ArtistRow, error) { + var a ArtistRow + err := db.pool.QueryRow(ctx, ` + SELECT a.id, a.metadata_id, am.foreign_artist_id, am.name, + a.quality_profile_id, a.metadata_profile_id, a.root_folder_id, + a.path, a.monitored, a.monitor_new_items + FROM artists a + JOIN artist_metadata am ON a.metadata_id = am.id + WHERE am.foreign_artist_id = $1 + `, foreignArtistID).Scan( + &a.ID, &a.MetadataID, &a.ForeignArtistID, &a.Name, + &a.QualityProfileID, &a.MetadataProfileID, &a.RootFolderID, + &a.Path, &a.Monitored, &a.MonitorNewItems, + ) + if err != nil { + return nil, err + } + return &a, nil +} + +type ArtistUpdate struct { + QualityProfileID *string `json:"quality_profile_id"` + MetadataProfileID *string `json:"metadata_profile_id"` + RootFolderID *string `json:"root_folder_id"` + Path *string `json:"path"` + Monitored *bool `json:"monitored"` + MonitorNewItems *string `json:"monitor_new_items"` +} + +func (db *DB) UpdateArtistByForeignID(ctx context.Context, foreignArtistID string, update ArtistUpdate) (*ArtistRow, error) { + metadataRow, err := db.GetArtistMetadataByForeignID(ctx, foreignArtistID) + if err != nil { + return nil, err + } + + if update.Monitored != nil { + _, err = db.pool.Exec(ctx, ` + UPDATE artists SET monitored = $1 WHERE metadata_id = $2 + `, *update.Monitored, metadataRow.ID) + if err != nil { + return nil, err + } + } + + if update.Path != nil { + _, err = db.pool.Exec(ctx, ` + UPDATE artists SET path = $1 WHERE metadata_id = $2 + `, *update.Path, metadataRow.ID) + if err != nil { + return nil, err + } + } + + if update.QualityProfileID != nil { + var qpID *uuid.UUID + if *update.QualityProfileID != "" { + parsed, err := uuid.Parse(*update.QualityProfileID) + if err == nil { + qpID = &parsed + } + } + _, err = db.pool.Exec(ctx, ` + UPDATE artists SET quality_profile_id = $1 WHERE metadata_id = $2 + `, qpID, metadataRow.ID) + if err != nil { + return nil, err + } + } + + if update.RootFolderID != nil { + var rfID *uuid.UUID + if *update.RootFolderID != "" { + parsed, err := uuid.Parse(*update.RootFolderID) + if err == nil { + rfID = &parsed + } + } + _, err = db.pool.Exec(ctx, ` + UPDATE artists SET root_folder_id = $1 WHERE metadata_id = $2 + `, rfID, metadataRow.ID) + if err != nil { + return nil, err + } + } + + if update.MonitorNewItems != nil { + _, err = db.pool.Exec(ctx, ` + UPDATE artists SET monitor_new_items = $1 WHERE metadata_id = $2 + `, *update.MonitorNewItems, metadataRow.ID) + if err != nil { + return nil, err + } + } + + return db.GetArtistByForeignID(ctx, foreignArtistID) +} + +func (db *DB) GetArtistMetadataByForeignID(ctx context.Context, foreignArtistID string) (*ArtistMetadataRow, error) { + var a ArtistMetadataRow + err := db.pool.QueryRow(ctx, ` + SELECT id, foreign_artist_id, name, sort_name, artist_type, genres, created_at, updated_at + FROM artist_metadata + WHERE foreign_artist_id = $1 + `, foreignArtistID).Scan(&a.ID, &a.ForeignArtistID, &a.Name, &a.SortName, &a.ArtistType, &a.Genres, &a.CreatedAt, &a.UpdatedAt) + if err != nil { + return nil, err + } + return &a, nil +} diff --git a/internal/services/download.go b/internal/services/download.go index 6bbc0a6..7055cba 100644 --- a/internal/services/download.go +++ b/internal/services/download.go @@ -101,6 +101,10 @@ func Sync( idStr := id.String() artistMetadataID = &idStr log.Info().Str("artist", artist.Name).Str("id", idStr).Msg("stored artist metadata") + + if _, err := db.UpsertArtist(ctx, id); err != nil { + log.Warn().Err(err).Str("artist", artist.Name).Msg("failed to create artist library entry") + } } } @@ -306,7 +310,7 @@ func RefreshArtist( return nil, &NotFoundError{Message: "database not available"} } - existingArtist, err := db.GetArtistByForeignID(ctx, foreignArtistID) + existingArtist, err := db.GetArtistMetadataByForeignID(ctx, foreignArtistID) if err != nil { return nil, &NotFoundError{Message: "artist not found: " + foreignArtistID} } diff --git a/testing/e2e/edit_artist_test.go b/testing/e2e/edit_artist_test.go new file mode 100644 index 0000000..5972332 --- /dev/null +++ b/testing/e2e/edit_artist_test.go @@ -0,0 +1,222 @@ +package e2e + +import ( + "context" + "testing" + "time" + + "github.com/fujin/music-agregator/testing/e2e/testutil" +) + +// TestEditArtist_Flow covers section 1.4 of FLOWS.md: +// 1. User changes quality profile, root folder, monitoring status +// 2. Persist to artists table +func TestEditArtist_Flow(t *testing.T) { + env := testutil.NewTestEnv(t) + defer env.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + artistName := "Morcheeba" + + 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, + "store": true, + "download": false, + }) + if err != nil { + t.Fatalf("sync failed: %v", err) + } + syncResp.AssertStatus(t, 200) + + var syncResult struct { + ArtistID string `json:"artist_id"` + ArtistName string `json:"artist_name"` + } + if err := syncResp.DecodeJSON(&syncResult); err != nil { + t.Fatalf("failed to decode sync response: %v", err) + } + + artistID := syncResult.ArtistID + + t.Run("GetArtistSettings", func(t *testing.T) { + getResp, err := env.GET("/api/artists/" + artistID) + if err != nil { + t.Fatalf("get artist failed: %v", err) + } + getResp.AssertStatus(t, 200) + + var artist struct { + ID string `json:"id"` + Name string `json:"name"` + Monitored bool `json:"monitored"` + } + if err := getResp.DecodeJSON(&artist); err != nil { + t.Fatalf("failed to decode artist: %v", err) + } + + if artist.Name != syncResult.ArtistName { + t.Errorf("expected name=%q, got %q", syncResult.ArtistName, artist.Name) + } + }) + + t.Run("UpdateMonitoredStatus", func(t *testing.T) { + editResp, err := env.PUT("/api/artists/"+artistID, map[string]any{ + "monitored": false, + }) + if err != nil { + t.Fatalf("edit request failed: %v", err) + } + editResp.AssertStatus(t, 200) + + var result struct { + ID string `json:"id"` + Monitored bool `json:"monitored"` + } + if err := editResp.DecodeJSON(&result); err != nil { + t.Fatalf("failed to decode edit response: %v", err) + } + + if result.Monitored != false { + t.Error("expected monitored=false after edit") + } + + getResp, _ := env.GET("/api/artists/" + artistID) + var artist struct { + Monitored bool `json:"monitored"` + } + getResp.DecodeJSON(&artist) + if artist.Monitored != false { + t.Error("expected monitored=false to persist") + } + }) + + t.Run("UpdateQualityProfile", func(t *testing.T) { + editResp, err := env.PUT("/api/artists/"+artistID, map[string]any{ + "quality_profile_id": "test-profile-id", + }) + if err != nil { + t.Fatalf("edit request failed: %v", err) + } + editResp.AssertStatus(t, 200) + + var result struct { + QualityProfileID *string `json:"quality_profile_id"` + } + if err := editResp.DecodeJSON(&result); err != nil { + t.Fatalf("failed to decode edit response: %v", err) + } + }) + + t.Run("UpdateRootFolder", func(t *testing.T) { + editResp, err := env.PUT("/api/artists/"+artistID, map[string]any{ + "root_folder_id": "test-folder-id", + "path": "/music/morcheeba", + }) + if err != nil { + t.Fatalf("edit request failed: %v", err) + } + editResp.AssertStatus(t, 200) + + var result struct { + RootFolderID *string `json:"root_folder_id"` + Path *string `json:"path"` + } + if err := editResp.DecodeJSON(&result); err != nil { + t.Fatalf("failed to decode edit response: %v", err) + } + + if result.Path == nil || *result.Path != "/music/morcheeba" { + t.Errorf("expected path=/music/morcheeba, got %v", result.Path) + } + }) + + t.Cleanup(func() { + env.CleanupArtistByName(context.Background(), syncResult.ArtistName) + }) +} + +func TestEditArtist_NotFound(t *testing.T) { + env := testutil.NewTestEnv(t) + defer env.Close() + + editResp, err := env.PUT("/api/artists/nonexistent-artist-id-99999", map[string]any{ + "monitored": false, + }) + if err != nil { + t.Fatalf("edit request failed: %v", err) + } + + editResp.AssertStatus(t, 404) +} + +func TestEditArtist_PartialUpdate(t *testing.T) { + env := testutil.NewTestEnv(t) + defer env.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + artistName := "Zero 7" + + 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, + "store": true, + "download": false, + }) + if err != nil { + t.Fatalf("sync failed: %v", err) + } + syncResp.AssertStatus(t, 200) + + var syncResult struct { + ArtistID string `json:"artist_id"` + ArtistName string `json:"artist_name"` + } + syncResp.DecodeJSON(&syncResult) + + firstEdit, err := env.PUT("/api/artists/"+syncResult.ArtistID, map[string]any{ + "monitored": false, + "path": "/music/zero7", + }) + if err != nil { + t.Fatalf("first edit failed: %v", err) + } + firstEdit.AssertStatus(t, 200) + + secondEdit, err := env.PUT("/api/artists/"+syncResult.ArtistID, map[string]any{ + "monitored": true, + }) + if err != nil { + t.Fatalf("second edit failed: %v", err) + } + secondEdit.AssertStatus(t, 200) + + getResp, _ := env.GET("/api/artists/" + syncResult.ArtistID) + var artist struct { + Monitored bool `json:"monitored"` + Path *string `json:"path"` + } + getResp.DecodeJSON(&artist) + + if artist.Monitored != true { + t.Error("expected monitored=true after second edit") + } + + if artist.Path == nil || *artist.Path != "/music/zero7" { + t.Errorf("expected path to be preserved, got %v", artist.Path) + } + + t.Cleanup(func() { + env.CleanupArtistByName(context.Background(), syncResult.ArtistName) + }) +} diff --git a/testing/e2e/testutil/testutil.go b/testing/e2e/testutil/testutil.go index 4471f34..364bae8 100644 --- a/testing/e2e/testutil/testutil.go +++ b/testing/e2e/testutil/testutil.go @@ -188,6 +188,11 @@ func (e *TestEnv) DELETE(path string) (*APIResponse, error) { return e.Do(APIRequest{Method: http.MethodDelete, Path: path}) } +// PUT is a convenience method for PUT requests. +func (e *TestEnv) PUT(path string, body any) (*APIResponse, error) { + return e.Do(APIRequest{Method: http.MethodPut, Path: path, Body: body}) +} + // DecodeJSON decodes the response body into the given value. func (r *APIResponse) DecodeJSON(v any) error { return json.Unmarshal(r.Body, v)