a1f6701bac
- gRPC service with MusicBrainz provider - PostgreSQL schema with migrations - Service layer with database-first caching - Repository pattern for data access - YAML configuration support - Research documentation for 17 music metadata projects
239 lines
6.0 KiB
Go
239 lines
6.0 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/metadata-agregator/internal/domain"
|
|
"github.com/metadata-agregator/internal/repository"
|
|
)
|
|
|
|
type AlbumRepository struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewAlbumRepository(pool *pgxpool.Pool) *AlbumRepository {
|
|
return &AlbumRepository{pool: pool}
|
|
}
|
|
|
|
func (r *AlbumRepository) GetByID(ctx context.Context, id string) (*domain.Album, error) {
|
|
query := `
|
|
SELECT id, title, album_type, release_date, upc, total_tracks, total_discs,
|
|
cover_url, source, source_id
|
|
FROM albums
|
|
WHERE id = $1`
|
|
|
|
album, err := r.scanAlbum(ctx, query, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := r.loadRelations(ctx, album); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return album, nil
|
|
}
|
|
|
|
func (r *AlbumRepository) GetByExternalID(ctx context.Context, source, sourceID string) (*domain.Album, error) {
|
|
query := `
|
|
SELECT a.id, a.title, a.album_type, a.release_date, a.upc, a.total_tracks,
|
|
a.total_discs, a.cover_url, a.source, a.source_id
|
|
FROM albums a
|
|
JOIN album_external_ids e ON a.id = e.album_id
|
|
WHERE e.source = $1 AND e.source_id = $2`
|
|
|
|
album, err := r.scanAlbum(ctx, query, source, sourceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := r.loadRelations(ctx, album); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return album, nil
|
|
}
|
|
|
|
func (r *AlbumRepository) GetByArtistID(ctx context.Context, artistID string, limit, offset int) (*domain.SearchResult[domain.Album], error) {
|
|
countQuery := `
|
|
SELECT COUNT(DISTINCT a.id)
|
|
FROM albums a
|
|
JOIN album_artists aa ON a.id = aa.album_id
|
|
JOIN artist_external_ids ae ON aa.artist_id = ae.artist_id
|
|
WHERE ae.source_id = $1`
|
|
|
|
searchQuery := `
|
|
SELECT DISTINCT a.id, a.title, a.album_type, a.release_date, a.upc,
|
|
a.total_tracks, a.total_discs, a.cover_url, a.source, a.source_id
|
|
FROM albums a
|
|
JOIN album_artists aa ON a.id = aa.album_id
|
|
JOIN artist_external_ids ae ON aa.artist_id = ae.artist_id
|
|
WHERE ae.source_id = $1
|
|
ORDER BY a.release_date DESC NULLS LAST
|
|
LIMIT $2 OFFSET $3`
|
|
|
|
var total int
|
|
if err := r.pool.QueryRow(ctx, countQuery, artistID).Scan(&total); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rows, err := r.pool.Query(ctx, searchQuery, artistID, limit, offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var albums []domain.Album
|
|
for rows.Next() {
|
|
album, err := r.scanAlbumFromRow(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
albums = append(albums, *album)
|
|
}
|
|
|
|
return &domain.SearchResult[domain.Album]{
|
|
Items: albums,
|
|
Total: total,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
}, nil
|
|
}
|
|
|
|
func (r *AlbumRepository) Save(ctx context.Context, album *domain.Album) error {
|
|
tx, err := r.pool.Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var source, sourceID string
|
|
if len(album.ExternalIDs) > 0 {
|
|
source = album.ExternalIDs[0].Source
|
|
sourceID = album.ExternalIDs[0].SourceID
|
|
}
|
|
|
|
query := `
|
|
INSERT INTO albums (id, title, album_type, release_date, upc, total_tracks,
|
|
total_discs, cover_url, source, source_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
ON CONFLICT (id) DO UPDATE SET
|
|
title = EXCLUDED.title,
|
|
album_type = EXCLUDED.album_type,
|
|
release_date = EXCLUDED.release_date,
|
|
upc = EXCLUDED.upc,
|
|
total_tracks = EXCLUDED.total_tracks,
|
|
total_discs = EXCLUDED.total_discs,
|
|
cover_url = EXCLUDED.cover_url,
|
|
updated_at = now()`
|
|
|
|
_, err = tx.Exec(ctx, query,
|
|
album.ID, album.Title, nullString(album.Type), album.ReleaseDate,
|
|
nullString(album.UPC), album.TotalTracks, album.TotalDiscs,
|
|
nullString(album.CoverURL), source, sourceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, ext := range album.ExternalIDs {
|
|
extQuery := `
|
|
INSERT INTO album_external_ids (album_id, source, source_id, url)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (album_id, source, source_id) DO UPDATE SET
|
|
url = EXCLUDED.url,
|
|
fetched_at = now()`
|
|
|
|
_, err = tx.Exec(ctx, extQuery, album.ID, ext.Source, ext.SourceID, nullString(ext.URL))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
for _, ac := range album.Artists {
|
|
artistQuery := `
|
|
INSERT INTO album_artists (album_id, artist_id, role, position)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (album_id, artist_id, role) DO NOTHING`
|
|
|
|
_, err = tx.Exec(ctx, artistQuery, album.ID, ac.Artist.ID, ac.Role, ac.Position)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
func (r *AlbumRepository) scanAlbum(ctx context.Context, query string, args ...any) (*domain.Album, error) {
|
|
row := r.pool.QueryRow(ctx, query, args...)
|
|
return r.scanAlbumRow(row)
|
|
}
|
|
|
|
func (r *AlbumRepository) scanAlbumFromRow(row pgx.Row) (*domain.Album, error) {
|
|
return r.scanAlbumRow(row)
|
|
}
|
|
|
|
func (r *AlbumRepository) scanAlbumRow(row pgx.Row) (*domain.Album, error) {
|
|
var (
|
|
album domain.Album
|
|
albumType *string
|
|
releaseDate *time.Time
|
|
upc *string
|
|
totalTracks *int
|
|
totalDiscs *int
|
|
coverURL *string
|
|
source string
|
|
sourceID *string
|
|
)
|
|
|
|
err := row.Scan(
|
|
&album.ID, &album.Title, &albumType, &releaseDate, &upc,
|
|
&totalTracks, &totalDiscs, &coverURL, &source, &sourceID,
|
|
)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, repository.ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
album.Type = derefString(albumType)
|
|
album.ReleaseDate = releaseDate
|
|
album.UPC = derefString(upc)
|
|
if totalTracks != nil {
|
|
album.TotalTracks = *totalTracks
|
|
}
|
|
if totalDiscs != nil {
|
|
album.TotalDiscs = *totalDiscs
|
|
}
|
|
album.CoverURL = derefString(coverURL)
|
|
|
|
return &album, nil
|
|
}
|
|
|
|
func (r *AlbumRepository) loadRelations(ctx context.Context, album *domain.Album) error {
|
|
extQuery := `SELECT source, source_id, url FROM album_external_ids WHERE album_id = $1`
|
|
rows, err := r.pool.Query(ctx, extQuery, album.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var ext domain.ExternalID
|
|
var url *string
|
|
if err := rows.Scan(&ext.Source, &ext.SourceID, &url); err != nil {
|
|
return err
|
|
}
|
|
ext.URL = derefString(url)
|
|
album.ExternalIDs = append(album.ExternalIDs, ext)
|
|
}
|
|
|
|
return rows.Err()
|
|
}
|