feat: add refresh and delete artist endpoints (sections 1.2, 1.3)
- Add POST /api/artists/{id}/refresh to re-fetch metadata from gRPC service
- Add DELETE /api/artists/{id} with cascade delete via PostgreSQL
- Add e2e tests for both flows covering happy path, not found, idempotency
- Extend testutil with GetArtistUpdatedAt, CountAlbumsByArtist, DELETE helper
This commit is contained in:
@@ -269,6 +269,60 @@ func (h *Handlers) LibraryStats(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) RefreshArtist(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
|
||||
}
|
||||
|
||||
result, err := services.RefreshArtist(r.Context(), artistID, h.MetadataClient, h.DB)
|
||||
if err != nil {
|
||||
if _, ok := err.(*services.NotFoundError); ok {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handlers) DeleteArtist(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
|
||||
}
|
||||
|
||||
deleted, err := h.DB.DeleteArtistByForeignID(r.Context(), artistID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if !deleted {
|
||||
writeError(w, http.StatusNotFound, "artist not found: "+artistID)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"deleted": true,
|
||||
"message": "artist and related data deleted",
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
@@ -43,6 +43,11 @@ func NewRouter(h *Handlers) *chi.Mux {
|
||||
|
||||
r.Post("/sync", h.Sync)
|
||||
|
||||
r.Route("/artists", func(r chi.Router) {
|
||||
r.Post("/{id}/refresh", h.RefreshArtist)
|
||||
r.Delete("/{id}", h.DeleteArtist)
|
||||
})
|
||||
|
||||
r.Route("/library", func(r chi.Router) {
|
||||
r.Get("/artists", h.ListLibraryArtists)
|
||||
r.Get("/albums", h.ListLibraryAlbums)
|
||||
|
||||
@@ -250,3 +250,41 @@ func (db *DB) CountAlbums(ctx context.Context) (int64, error) {
|
||||
err := db.pool.QueryRow(ctx, "SELECT COUNT(*) FROM albums").Scan(&count)
|
||||
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, `
|
||||
SELECT COUNT(*) FROM albums WHERE artist_metadata_id = $1
|
||||
`, artistMetadataID).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (db *DB) TouchArtistUpdatedAt(ctx context.Context, artistMetadataID uuid.UUID) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
UPDATE artist_metadata SET updated_at = NOW() WHERE id = $1
|
||||
`, artistMetadataID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) DeleteArtistByForeignID(ctx context.Context, foreignArtistID string) (bool, error) {
|
||||
result, err := db.pool.Exec(ctx, `
|
||||
DELETE FROM artist_metadata WHERE foreign_artist_id = $1
|
||||
`, foreignArtistID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return result.RowsAffected() > 0, nil
|
||||
}
|
||||
|
||||
@@ -289,6 +289,96 @@ func parseUUID(s string) ([16]byte, error) {
|
||||
return id, nil
|
||||
}
|
||||
|
||||
type RefreshResult struct {
|
||||
ArtistID string `json:"artist_id"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumsUpdated int `json:"albums_updated"`
|
||||
AlbumsAdded int `json:"albums_added"`
|
||||
}
|
||||
|
||||
func RefreshArtist(
|
||||
ctx context.Context,
|
||||
foreignArtistID string,
|
||||
metadataClient *metadata.Client,
|
||||
db *database.DB,
|
||||
) (*RefreshResult, error) {
|
||||
if db == nil {
|
||||
return nil, &NotFoundError{Message: "database not available"}
|
||||
}
|
||||
|
||||
existingArtist, err := db.GetArtistByForeignID(ctx, foreignArtistID)
|
||||
if err != nil {
|
||||
return nil, &NotFoundError{Message: "artist not found: " + foreignArtistID}
|
||||
}
|
||||
|
||||
artist, err := metadataClient.GetArtist(ctx, foreignArtistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dbArtist := &database.Artist{
|
||||
ID: artist.Id,
|
||||
Name: artist.Name,
|
||||
SortName: artist.SortName,
|
||||
ArtistType: artist.ArtistType,
|
||||
Description: artist.Description,
|
||||
}
|
||||
for _, g := range artist.Genres {
|
||||
dbArtist.Genres = append(dbArtist.Genres, database.Genre{ID: g.Id, Name: g.Name})
|
||||
}
|
||||
for _, e := range artist.ExternalIds {
|
||||
dbArtist.ExternalIDs = append(dbArtist.ExternalIDs, database.ExternalID{
|
||||
Source: e.Source,
|
||||
SourceID: e.SourceId,
|
||||
URL: e.Url,
|
||||
})
|
||||
}
|
||||
|
||||
artistMetadataID, err := db.UpsertArtistMetadata(ctx, dbArtist)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existingAlbumCount, _ := db.CountAlbumsByArtist(ctx, existingArtist.ID)
|
||||
|
||||
albumsResponse, err := metadataClient.GetArtistAlbums(ctx, foreignArtistID, 500, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var albumsUpdated int
|
||||
for _, album := range albumsResponse.Albums {
|
||||
dbAlbum := &database.Album{
|
||||
ID: album.Id,
|
||||
Title: album.Title,
|
||||
AlbumType: album.AlbumType,
|
||||
ReleaseDate: album.ReleaseDate,
|
||||
}
|
||||
for _, g := range album.Genres {
|
||||
dbAlbum.Genres = append(dbAlbum.Genres, database.Genre{ID: g.Id, Name: g.Name})
|
||||
}
|
||||
|
||||
if _, err := db.UpsertAlbum(ctx, dbAlbum, artistMetadataID); err != nil {
|
||||
log.Warn().Err(err).Str("album", album.Title).Msg("failed to upsert album during refresh")
|
||||
} else {
|
||||
albumsUpdated++
|
||||
}
|
||||
}
|
||||
|
||||
newAlbumCount, _ := db.CountAlbumsByArtist(ctx, artistMetadataID)
|
||||
albumsAdded := int(newAlbumCount - existingAlbumCount)
|
||||
if albumsAdded < 0 {
|
||||
albumsAdded = 0
|
||||
}
|
||||
|
||||
return &RefreshResult{
|
||||
ArtistID: foreignArtistID,
|
||||
ArtistName: artist.Name,
|
||||
AlbumsUpdated: albumsUpdated,
|
||||
AlbumsAdded: albumsAdded,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type NotFoundError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user