b08a0b1646
- 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
291 lines
7.9 KiB
Go
291 lines
7.9 KiB
Go
package database
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
type DB struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func New(ctx context.Context, databaseURL string) (*DB, error) {
|
|
pool, err := pgxpool.New(ctx, databaseURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := pool.Ping(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &DB{pool: pool}, nil
|
|
}
|
|
|
|
func (db *DB) Close() {
|
|
db.pool.Close()
|
|
}
|
|
|
|
type Artist struct {
|
|
ID string
|
|
Name string
|
|
SortName string
|
|
ArtistType string
|
|
Description string
|
|
Genres []Genre
|
|
ExternalIDs []ExternalID
|
|
}
|
|
|
|
type Album struct {
|
|
ID string
|
|
Title string
|
|
AlbumType string
|
|
ReleaseDate string
|
|
Genres []Genre
|
|
}
|
|
|
|
type Genre struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type ExternalID struct {
|
|
Source string `json:"source"`
|
|
SourceID string `json:"source_id"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
type ArtistMetadataRow struct {
|
|
ID uuid.UUID `json:"id"`
|
|
ForeignArtistID *string `json:"foreign_artist_id"`
|
|
Name string `json:"name"`
|
|
SortName *string `json:"sort_name"`
|
|
ArtistType *string `json:"artist_type"`
|
|
Genres json.RawMessage `json:"genres"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type AlbumRow struct {
|
|
ID uuid.UUID `json:"id"`
|
|
ArtistMetadataID uuid.UUID `json:"artist_metadata_id"`
|
|
ForeignAlbumID *string `json:"foreign_album_id"`
|
|
Title string `json:"title"`
|
|
AlbumType *string `json:"album_type"`
|
|
ReleaseDate *time.Time `json:"release_date"`
|
|
Monitored bool `json:"monitored"`
|
|
AddedAt time.Time `json:"added_at"`
|
|
}
|
|
|
|
type AlbumWithArtistRow struct {
|
|
ID uuid.UUID `json:"id"`
|
|
ForeignAlbumID *string `json:"foreign_album_id"`
|
|
Title string `json:"title"`
|
|
AlbumType *string `json:"album_type"`
|
|
ReleaseDate *time.Time `json:"release_date"`
|
|
Monitored bool `json:"monitored"`
|
|
AddedAt time.Time `json:"added_at"`
|
|
ArtistID uuid.UUID `json:"artist_id"`
|
|
ArtistName string `json:"artist_name"`
|
|
}
|
|
|
|
func (db *DB) UpsertArtistMetadata(ctx context.Context, artist *Artist) (uuid.UUID, error) {
|
|
id, err := uuid.Parse(artist.ID)
|
|
if err != nil {
|
|
id = uuid.New()
|
|
}
|
|
|
|
genres, _ := json.Marshal(artist.Genres)
|
|
links, _ := json.Marshal(artist.ExternalIDs)
|
|
|
|
var resultID uuid.UUID
|
|
err = db.pool.QueryRow(ctx, `
|
|
INSERT INTO artist_metadata (
|
|
id, foreign_artist_id, name, sort_name, disambiguation,
|
|
artist_type, status, overview, genres, links, updated_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())
|
|
ON CONFLICT (foreign_artist_id) DO UPDATE SET
|
|
name = EXCLUDED.name,
|
|
sort_name = EXCLUDED.sort_name,
|
|
artist_type = EXCLUDED.artist_type,
|
|
overview = EXCLUDED.overview,
|
|
genres = EXCLUDED.genres,
|
|
links = EXCLUDED.links,
|
|
updated_at = NOW()
|
|
RETURNING id
|
|
`, id, artist.ID, artist.Name, artist.SortName, artist.Description,
|
|
artist.ArtistType, "active", artist.Description, genres, links).Scan(&resultID)
|
|
|
|
return resultID, err
|
|
}
|
|
|
|
var cleanTitleRegex = regexp.MustCompile(`[^a-z0-9]`)
|
|
|
|
func (db *DB) UpsertAlbum(ctx context.Context, album *Album, artistMetadataID uuid.UUID) (uuid.UUID, error) {
|
|
id, err := uuid.Parse(album.ID)
|
|
if err != nil {
|
|
id = uuid.New()
|
|
}
|
|
|
|
genres, _ := json.Marshal(album.Genres)
|
|
images, _ := json.Marshal([]any{})
|
|
|
|
var releaseDate *time.Time
|
|
if album.ReleaseDate != "" {
|
|
if t, err := time.Parse("2006-01-02", album.ReleaseDate); err == nil {
|
|
releaseDate = &t
|
|
}
|
|
}
|
|
|
|
cleanTitle := cleanTitleRegex.ReplaceAllString(strings.ToLower(album.Title), "")
|
|
|
|
var resultID uuid.UUID
|
|
err = db.pool.QueryRow(ctx, `
|
|
INSERT INTO albums (
|
|
id, artist_metadata_id, foreign_album_id, title, clean_title,
|
|
overview, album_type, release_date, images, genres
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
ON CONFLICT (foreign_album_id) DO UPDATE SET
|
|
title = EXCLUDED.title,
|
|
album_type = EXCLUDED.album_type,
|
|
release_date = EXCLUDED.release_date,
|
|
genres = EXCLUDED.genres
|
|
RETURNING id
|
|
`, id, artistMetadataID, album.ID, album.Title, cleanTitle,
|
|
"", album.AlbumType, releaseDate, images, genres).Scan(&resultID)
|
|
|
|
return resultID, err
|
|
}
|
|
|
|
func (db *DB) ListArtists(ctx context.Context) ([]ArtistMetadataRow, error) {
|
|
rows, err := db.pool.Query(ctx, `
|
|
SELECT id, foreign_artist_id, name, sort_name, artist_type, genres, created_at, updated_at
|
|
FROM artist_metadata
|
|
ORDER BY name
|
|
`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var artists []ArtistMetadataRow
|
|
for rows.Next() {
|
|
var a ArtistMetadataRow
|
|
err := rows.Scan(&a.ID, &a.ForeignArtistID, &a.Name, &a.SortName, &a.ArtistType, &a.Genres, &a.CreatedAt, &a.UpdatedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
artists = append(artists, a)
|
|
}
|
|
|
|
return artists, nil
|
|
}
|
|
|
|
func (db *DB) ListAlbumsByArtist(ctx context.Context, artistMetadataID uuid.UUID) ([]AlbumRow, error) {
|
|
rows, err := db.pool.Query(ctx, `
|
|
SELECT id, artist_metadata_id, foreign_album_id, title, album_type, release_date, monitored, added_at
|
|
FROM albums
|
|
WHERE artist_metadata_id = $1
|
|
ORDER BY release_date DESC NULLS LAST
|
|
`, artistMetadataID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var albums []AlbumRow
|
|
for rows.Next() {
|
|
var a AlbumRow
|
|
err := rows.Scan(&a.ID, &a.ArtistMetadataID, &a.ForeignAlbumID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.Monitored, &a.AddedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
albums = append(albums, a)
|
|
}
|
|
|
|
return albums, nil
|
|
}
|
|
|
|
func (db *DB) ListAllAlbums(ctx context.Context) ([]AlbumWithArtistRow, error) {
|
|
rows, err := db.pool.Query(ctx, `
|
|
SELECT
|
|
a.id, a.foreign_album_id, a.title, a.album_type, a.release_date, a.monitored, a.added_at,
|
|
am.id as artist_id, am.name as artist_name
|
|
FROM albums a
|
|
JOIN artist_metadata am ON a.artist_metadata_id = am.id
|
|
ORDER BY a.added_at DESC
|
|
`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var albums []AlbumWithArtistRow
|
|
for rows.Next() {
|
|
var a AlbumWithArtistRow
|
|
err := rows.Scan(&a.ID, &a.ForeignAlbumID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.Monitored, &a.AddedAt, &a.ArtistID, &a.ArtistName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
albums = append(albums, a)
|
|
}
|
|
|
|
return albums, nil
|
|
}
|
|
|
|
func (db *DB) CountArtists(ctx context.Context) (int64, error) {
|
|
var count int64
|
|
err := db.pool.QueryRow(ctx, "SELECT COUNT(*) FROM artist_metadata").Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
func (db *DB) CountAlbums(ctx context.Context) (int64, error) {
|
|
var count int64
|
|
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
|
|
}
|