feat: add album management endpoints (sections 2.1, 2.2, 2.3)
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/fujin/music-agregator/internal/metadata"
|
"github.com/fujin/music-agregator/internal/metadata"
|
||||||
"github.com/fujin/music-agregator/internal/services"
|
"github.com/fujin/music-agregator/internal/services"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
@@ -371,6 +372,211 @@ func (h *Handlers) EditArtist(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, artist)
|
writeJSON(w, http.StatusOK, artist)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) GetAlbum(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.DB == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "database not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
albumIDStr := chi.URLParam(r, "id")
|
||||||
|
albumID, err := parseUUID(albumIDStr)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid album ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
album, err := h.DB.GetAlbumDetailByID(r.Context(), albumID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "album not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, album)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) EditAlbum(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.DB == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "database not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
albumIDStr := chi.URLParam(r, "id")
|
||||||
|
albumID, err := parseUUID(albumIDStr)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid album ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var update struct {
|
||||||
|
Monitored *bool `json:"monitored"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if update.Monitored != nil {
|
||||||
|
if err := h.DB.UpdateAlbumMonitored(r.Context(), albumID, *update.Monitored); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if *update.Monitored {
|
||||||
|
hasFiles, _ := h.DB.HasTrackFiles(r.Context(), albumID)
|
||||||
|
if !hasFiles {
|
||||||
|
h.DB.AddToWantedAlbums(r.Context(), albumID)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
h.DB.RemoveFromWantedAlbums(r.Context(), albumID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
album, err := h.DB.GetAlbumDetailByID(r.Context(), albumID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "album not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, album)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) SearchAlbum(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.DB == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "database not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
albumIDStr := chi.URLParam(r, "id")
|
||||||
|
albumID, err := parseUUID(albumIDStr)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid album ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := services.SearchAlbum(r.Context(), albumID, h.DB, h.IndexerService)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "album not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) BulkMonitorArtistAlbums(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 req struct {
|
||||||
|
Monitored bool `json:"monitored"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
artist, err := h.DB.GetArtistMetadataByForeignID(r.Context(), artistID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "artist not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedCount, err := h.DB.BulkUpdateAlbumsMonitored(r.Context(), artist.ID, req.Monitored)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
albums, _ := h.DB.ListAlbumsByArtist(r.Context(), artist.ID)
|
||||||
|
for _, album := range albums {
|
||||||
|
if req.Monitored {
|
||||||
|
hasFiles, _ := h.DB.HasTrackFiles(r.Context(), album.ID)
|
||||||
|
if !hasFiles {
|
||||||
|
h.DB.AddToWantedAlbums(r.Context(), album.ID)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
h.DB.RemoveFromWantedAlbums(r.Context(), album.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"updated_count": updatedCount,
|
||||||
|
"monitored": req.Monitored,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) SearchArtistAlbums(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.SearchArtistAlbums(r.Context(), artistID, h.DB, h.IndexerService)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "artist not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) AddToBlocklist(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.DB == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "database not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
AlbumID string `json:"album_id"`
|
||||||
|
SourceTitle string `json:"source_title"`
|
||||||
|
GUID *string `json:"guid"`
|
||||||
|
TorrentHash *string `json:"torrent_hash"`
|
||||||
|
Indexer *string `json:"indexer"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
albumID, err := parseUUID(req.AlbumID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid album_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
artistID, err := h.DB.GetArtistIDByAlbum(r.Context(), albumID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "album not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.DB.AddToBlocklist(r.Context(), *artistID, albumID, req.SourceTitle, req.TorrentHash, req.Indexer); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"added": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUUID(s string) (uuid.UUID, error) {
|
||||||
|
return uuid.Parse(s)
|
||||||
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
|
|||||||
@@ -48,8 +48,18 @@ func NewRouter(h *Handlers) *chi.Mux {
|
|||||||
r.Put("/{id}", h.EditArtist)
|
r.Put("/{id}", h.EditArtist)
|
||||||
r.Post("/{id}/refresh", h.RefreshArtist)
|
r.Post("/{id}/refresh", h.RefreshArtist)
|
||||||
r.Delete("/{id}", h.DeleteArtist)
|
r.Delete("/{id}", h.DeleteArtist)
|
||||||
|
r.Put("/{id}/albums/monitor", h.BulkMonitorArtistAlbums)
|
||||||
|
r.Post("/{id}/search", h.SearchArtistAlbums)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
r.Route("/albums", func(r chi.Router) {
|
||||||
|
r.Get("/{id}", h.GetAlbum)
|
||||||
|
r.Put("/{id}", h.EditAlbum)
|
||||||
|
r.Post("/{id}/search", h.SearchAlbum)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Post("/blocklist", h.AddToBlocklist)
|
||||||
|
|
||||||
r.Route("/library", func(r chi.Router) {
|
r.Route("/library", func(r chi.Router) {
|
||||||
r.Get("/artists", h.ListLibraryArtists)
|
r.Get("/artists", h.ListLibraryArtists)
|
||||||
r.Get("/albums", h.ListLibraryAlbums)
|
r.Get("/albums", h.ListLibraryAlbums)
|
||||||
|
|||||||
@@ -416,3 +416,194 @@ func (db *DB) GetArtistMetadataByForeignID(ctx context.Context, foreignArtistID
|
|||||||
}
|
}
|
||||||
return &a, nil
|
return &a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetAlbumByID(ctx context.Context, albumID uuid.UUID) (*AlbumRow, error) {
|
||||||
|
var a AlbumRow
|
||||||
|
err := db.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, artist_metadata_id, foreign_album_id, title, album_type, release_date, monitored, added_at
|
||||||
|
FROM albums WHERE id = $1
|
||||||
|
`, albumID).Scan(&a.ID, &a.ArtistMetadataID, &a.ForeignAlbumID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.Monitored, &a.AddedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlbumDetailRow 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"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
ForeignArtistID *string `json:"foreign_artist_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetAlbumDetailByID(ctx context.Context, albumID uuid.UUID) (*AlbumDetailRow, error) {
|
||||||
|
var a AlbumDetailRow
|
||||||
|
err := db.pool.QueryRow(ctx, `
|
||||||
|
SELECT a.id, a.artist_metadata_id, a.foreign_album_id, a.title, a.album_type,
|
||||||
|
a.release_date, a.monitored, a.added_at, am.name, am.foreign_artist_id
|
||||||
|
FROM albums a
|
||||||
|
JOIN artist_metadata am ON a.artist_metadata_id = am.id
|
||||||
|
WHERE a.id = $1
|
||||||
|
`, albumID).Scan(&a.ID, &a.ArtistMetadataID, &a.ForeignAlbumID, &a.Title, &a.AlbumType,
|
||||||
|
&a.ReleaseDate, &a.Monitored, &a.AddedAt, &a.ArtistName, &a.ForeignArtistID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UpdateAlbumMonitored(ctx context.Context, albumID uuid.UUID, monitored bool) error {
|
||||||
|
_, err := db.pool.Exec(ctx, `
|
||||||
|
UPDATE albums SET monitored = $1 WHERE id = $2
|
||||||
|
`, monitored, albumID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) BulkUpdateAlbumsMonitored(ctx context.Context, artistMetadataID uuid.UUID, monitored bool) (int64, error) {
|
||||||
|
result, err := db.pool.Exec(ctx, `
|
||||||
|
UPDATE albums SET monitored = $1 WHERE artist_metadata_id = $2
|
||||||
|
`, monitored, artistMetadataID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetMonitoredAlbumsByArtist(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 AND monitored = true
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
type WantedAlbumRow struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
AlbumID uuid.UUID `json:"album_id"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
SearchCount int `json:"search_count"`
|
||||||
|
LastSearchedAt *time.Time `json:"last_searched_at"`
|
||||||
|
AddedAt time.Time `json:"added_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) AddToWantedAlbums(ctx context.Context, albumID uuid.UUID) error {
|
||||||
|
_, err := db.pool.Exec(ctx, `
|
||||||
|
INSERT INTO wanted_albums (album_id)
|
||||||
|
VALUES ($1)
|
||||||
|
ON CONFLICT (album_id) DO NOTHING
|
||||||
|
`, albumID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) RemoveFromWantedAlbums(ctx context.Context, albumID uuid.UUID) error {
|
||||||
|
_, err := db.pool.Exec(ctx, `
|
||||||
|
DELETE FROM wanted_albums WHERE album_id = $1
|
||||||
|
`, albumID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) IsAlbumWanted(ctx context.Context, albumID uuid.UUID) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
err := db.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM wanted_albums WHERE album_id = $1
|
||||||
|
`, albumID).Scan(&count)
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) HasTrackFiles(ctx context.Context, albumID uuid.UUID) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
err := db.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM track_files WHERE album_id = $1
|
||||||
|
`, albumID).Scan(&count)
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type BlocklistEntry struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ArtistID uuid.UUID `json:"artist_id"`
|
||||||
|
AlbumID uuid.UUID `json:"album_id"`
|
||||||
|
SourceTitle string `json:"source_title"`
|
||||||
|
TorrentHash *string `json:"torrent_hash"`
|
||||||
|
Indexer *string `json:"indexer"`
|
||||||
|
Message *string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) AddToBlocklist(ctx context.Context, artistID, albumID uuid.UUID, sourceTitle string, torrentHash, indexer *string) error {
|
||||||
|
_, err := db.pool.Exec(ctx, `
|
||||||
|
INSERT INTO blocklist (artist_id, album_id, source_title, torrent_hash, indexer)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
`, artistID, albumID, sourceTitle, torrentHash, indexer)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) IsBlocklisted(ctx context.Context, sourceTitle string, torrentHash *string) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
if torrentHash != nil && *torrentHash != "" {
|
||||||
|
err := db.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM blocklist WHERE source_title = $1 OR torrent_hash = $2
|
||||||
|
`, sourceTitle, *torrentHash).Scan(&count)
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
err := db.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM blocklist WHERE source_title = $1
|
||||||
|
`, sourceTitle).Scan(&count)
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) ListBlocklist(ctx context.Context) ([]BlocklistEntry, error) {
|
||||||
|
rows, err := db.pool.Query(ctx, `
|
||||||
|
SELECT id, artist_id, album_id, source_title, torrent_hash, indexer, message
|
||||||
|
FROM blocklist ORDER BY date DESC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var entries []BlocklistEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var e BlocklistEntry
|
||||||
|
err := rows.Scan(&e.ID, &e.ArtistID, &e.AlbumID, &e.SourceTitle, &e.TorrentHash, &e.Indexer, &e.Message)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetArtistIDByAlbum(ctx context.Context, albumID uuid.UUID) (*uuid.UUID, error) {
|
||||||
|
var artistID uuid.UUID
|
||||||
|
err := db.pool.QueryRow(ctx, `
|
||||||
|
SELECT ar.id FROM artists ar
|
||||||
|
JOIN artist_metadata am ON ar.metadata_id = am.id
|
||||||
|
JOIN albums a ON a.artist_metadata_id = am.id
|
||||||
|
WHERE a.id = $1
|
||||||
|
`, albumID).Scan(&artistID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &artistID, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/fujin/music-agregator/internal/database"
|
||||||
|
"github.com/fujin/music-agregator/internal/indexer"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RankedSearchResult struct {
|
||||||
|
indexer.SearchResult
|
||||||
|
Quality string `json:"quality"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlbumSearchResult struct {
|
||||||
|
AlbumID string `json:"album_id"`
|
||||||
|
AlbumTitle string `json:"album_title"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
Results []RankedSearchResult `json:"results"`
|
||||||
|
TotalResults int `json:"total_results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func SearchAlbum(
|
||||||
|
ctx context.Context,
|
||||||
|
albumID uuid.UUID,
|
||||||
|
db *database.DB,
|
||||||
|
indexerService *IndexerService,
|
||||||
|
) (*AlbumSearchResult, error) {
|
||||||
|
album, err := db.GetAlbumDetailByID(ctx, albumID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var year *uint32
|
||||||
|
if album.ReleaseDate != nil {
|
||||||
|
y := uint32(album.ReleaseDate.Year())
|
||||||
|
year = &y
|
||||||
|
}
|
||||||
|
|
||||||
|
criteria := &indexer.MusicSearchCriteria{
|
||||||
|
Artist: album.ArtistName,
|
||||||
|
Album: &album.Title,
|
||||||
|
Year: year,
|
||||||
|
Limit: 100,
|
||||||
|
Offset: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := indexerService.Search(ctx, criteria, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var rankedResults []RankedSearchResult
|
||||||
|
for _, r := range results {
|
||||||
|
blocked, _ := db.IsBlocklisted(ctx, r.Title, r.Infohash)
|
||||||
|
if blocked {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
quality := detectQuality(r.Title)
|
||||||
|
score := calculateScore(quality, r.Seeders)
|
||||||
|
|
||||||
|
rankedResults = append(rankedResults, RankedSearchResult{
|
||||||
|
SearchResult: r,
|
||||||
|
Quality: quality,
|
||||||
|
Score: score,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(rankedResults, func(i, j int) bool {
|
||||||
|
return rankedResults[i].Score > rankedResults[j].Score
|
||||||
|
})
|
||||||
|
|
||||||
|
return &AlbumSearchResult{
|
||||||
|
AlbumID: albumID.String(),
|
||||||
|
AlbumTitle: album.Title,
|
||||||
|
ArtistName: album.ArtistName,
|
||||||
|
Results: rankedResults,
|
||||||
|
TotalResults: len(rankedResults),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectQuality(title string) string {
|
||||||
|
titleLower := strings.ToLower(title)
|
||||||
|
|
||||||
|
if strings.Contains(titleLower, "flac") || strings.Contains(titleLower, "lossless") {
|
||||||
|
return "FLAC"
|
||||||
|
}
|
||||||
|
if strings.Contains(titleLower, "24bit") || strings.Contains(titleLower, "24-bit") || strings.Contains(titleLower, "hi-res") {
|
||||||
|
return "FLAC-24bit"
|
||||||
|
}
|
||||||
|
if strings.Contains(titleLower, "320") || strings.Contains(titleLower, "mp3-320") {
|
||||||
|
return "MP3-320"
|
||||||
|
}
|
||||||
|
if strings.Contains(titleLower, "v0") || strings.Contains(titleLower, "vbr") {
|
||||||
|
return "MP3-VBR"
|
||||||
|
}
|
||||||
|
if strings.Contains(titleLower, "mp3") {
|
||||||
|
return "MP3"
|
||||||
|
}
|
||||||
|
if strings.Contains(titleLower, "aac") {
|
||||||
|
return "AAC"
|
||||||
|
}
|
||||||
|
if strings.Contains(titleLower, "ogg") {
|
||||||
|
return "OGG"
|
||||||
|
}
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateScore(quality string, seeders *int) float64 {
|
||||||
|
var score float64
|
||||||
|
|
||||||
|
switch quality {
|
||||||
|
case "FLAC-24bit":
|
||||||
|
score = 1000
|
||||||
|
case "FLAC":
|
||||||
|
score = 900
|
||||||
|
case "MP3-320":
|
||||||
|
score = 700
|
||||||
|
case "MP3-VBR":
|
||||||
|
score = 600
|
||||||
|
case "MP3":
|
||||||
|
score = 500
|
||||||
|
case "AAC":
|
||||||
|
score = 400
|
||||||
|
case "OGG":
|
||||||
|
score = 350
|
||||||
|
default:
|
||||||
|
score = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
if seeders != nil && *seeders > 0 {
|
||||||
|
score += float64(*seeders) * 0.1
|
||||||
|
if *seeders > 100 {
|
||||||
|
score += 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArtistSearchResult struct {
|
||||||
|
ArtistID string `json:"artist_id"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
AlbumsSearched int `json:"albums_searched"`
|
||||||
|
Results []AlbumBriefResult `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlbumBriefResult struct {
|
||||||
|
AlbumID string `json:"album_id"`
|
||||||
|
AlbumTitle string `json:"album_title"`
|
||||||
|
ResultsCount int `json:"results_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func SearchArtistAlbums(
|
||||||
|
ctx context.Context,
|
||||||
|
foreignArtistID string,
|
||||||
|
db *database.DB,
|
||||||
|
indexerService *IndexerService,
|
||||||
|
) (*ArtistSearchResult, error) {
|
||||||
|
artist, err := db.GetArtistMetadataByForeignID(ctx, foreignArtistID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
albums, err := db.GetMonitoredAlbumsByArtist(ctx, artist.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []AlbumBriefResult
|
||||||
|
for _, album := range albums {
|
||||||
|
searchResult, err := SearchAlbum(ctx, album.ID, db, indexerService)
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, AlbumBriefResult{
|
||||||
|
AlbumID: album.ID.String(),
|
||||||
|
AlbumTitle: album.Title,
|
||||||
|
ResultsCount: 0,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, AlbumBriefResult{
|
||||||
|
AlbumID: album.ID.String(),
|
||||||
|
AlbumTitle: album.Title,
|
||||||
|
ResultsCount: searchResult.TotalResults,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ArtistSearchResult{
|
||||||
|
ArtistID: foreignArtistID,
|
||||||
|
ArtistName: artist.Name,
|
||||||
|
AlbumsSearched: len(albums),
|
||||||
|
Results: results,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
// Package e2e contains end-to-end tests for the music aggregator.
|
||||||
|
//
|
||||||
|
// This file covers Section 2 of FLOWS.md: Album Management
|
||||||
|
// - 2.1 Album Monitoring
|
||||||
|
// - 2.2 Album Search (Manual)
|
||||||
|
// - 2.3 Artist Search
|
||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fujin/music-agregator/testing/e2e/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAlbumMonitoring_Flow covers section 2.1 of FLOWS.md:
|
||||||
|
// 1. Toggle albums.monitored per album or bulk per artist
|
||||||
|
// 2. Only monitored albums eligible for search/download
|
||||||
|
// 3. Toggling monitored ON adds to wanted_albums if no track_files exist
|
||||||
|
func TestAlbumMonitoring_Flow(t *testing.T) {
|
||||||
|
env := testutil.NewTestEnv(t)
|
||||||
|
defer env.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
artistName := "Portishead"
|
||||||
|
if err := env.CleanupArtistByName(ctx, artistName); err != nil {
|
||||||
|
t.Fatalf("cleanup failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := env.CleanupWantedAlbums(ctx); err != nil {
|
||||||
|
t.Fatalf("cleanup wanted_albums failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
Results []struct {
|
||||||
|
AlbumID string `json:"album_id"`
|
||||||
|
AlbumTitle string `json:"album_title"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
if err := syncResp.DecodeJSON(&syncResult); err != nil {
|
||||||
|
t.Fatalf("failed to decode sync response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(syncResult.Results) == 0 {
|
||||||
|
t.Skip("no albums synced for test")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
env.CleanupArtistByName(context.Background(), artistName)
|
||||||
|
env.CleanupWantedAlbums(context.Background())
|
||||||
|
})
|
||||||
|
|
||||||
|
albums, err := env.GetAlbumsByArtistForeignID(ctx, syncResult.ArtistID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get albums: %v", err)
|
||||||
|
}
|
||||||
|
if len(albums) == 0 {
|
||||||
|
t.Fatal("expected at least one album")
|
||||||
|
}
|
||||||
|
|
||||||
|
testAlbumID := albums[0]["id"].(string)
|
||||||
|
|
||||||
|
t.Run("Step1_ToggleAlbumMonitoredOff", func(t *testing.T) {
|
||||||
|
resp, err := env.PUT("/api/albums/"+testAlbumID, map[string]any{
|
||||||
|
"monitored": false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
resp.AssertStatus(t, 200)
|
||||||
|
|
||||||
|
album, err := env.GetAlbumByID(ctx, testAlbumID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get album: %v", err)
|
||||||
|
}
|
||||||
|
if album["monitored"].(bool) != false {
|
||||||
|
t.Error("expected album to be unmonitored")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Step2_ToggleAlbumMonitoredOn_AddsToWanted", func(t *testing.T) {
|
||||||
|
wantedBefore, _ := env.CountWantedAlbums(ctx)
|
||||||
|
|
||||||
|
resp, err := env.PUT("/api/albums/"+testAlbumID, map[string]any{
|
||||||
|
"monitored": true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
resp.AssertStatus(t, 200)
|
||||||
|
|
||||||
|
album, err := env.GetAlbumByID(ctx, testAlbumID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get album: %v", err)
|
||||||
|
}
|
||||||
|
if album["monitored"].(bool) != true {
|
||||||
|
t.Error("expected album to be monitored")
|
||||||
|
}
|
||||||
|
|
||||||
|
isWanted, err := env.IsAlbumWanted(ctx, testAlbumID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to check wanted status: %v", err)
|
||||||
|
}
|
||||||
|
if !isWanted {
|
||||||
|
t.Error("expected album to be added to wanted_albums when monitored=true")
|
||||||
|
}
|
||||||
|
|
||||||
|
wantedAfter, _ := env.CountWantedAlbums(ctx)
|
||||||
|
if wantedAfter <= wantedBefore {
|
||||||
|
t.Errorf("expected wanted_albums count to increase, was %d, now %d", wantedBefore, wantedAfter)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Step3_ToggleMonitoredOff_RemovesFromWanted", func(t *testing.T) {
|
||||||
|
resp, err := env.PUT("/api/albums/"+testAlbumID, map[string]any{
|
||||||
|
"monitored": false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
resp.AssertStatus(t, 200)
|
||||||
|
|
||||||
|
isWanted, err := env.IsAlbumWanted(ctx, testAlbumID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to check wanted status: %v", err)
|
||||||
|
}
|
||||||
|
if isWanted {
|
||||||
|
t.Error("expected album to be removed from wanted_albums when monitored=false")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Step4_BulkMonitorArtistAlbums", func(t *testing.T) {
|
||||||
|
env.CleanupWantedAlbums(ctx)
|
||||||
|
|
||||||
|
resp, err := env.PUT("/api/artists/"+syncResult.ArtistID+"/albums/monitor", map[string]any{
|
||||||
|
"monitored": true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
resp.AssertStatus(t, 200)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
UpdatedCount int `json:"updated_count"`
|
||||||
|
}
|
||||||
|
if err := resp.DecodeJSON(&result); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.UpdatedCount == 0 {
|
||||||
|
t.Error("expected updated_count > 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
wantedAlbums, err := env.GetWantedAlbumsByArtist(ctx, syncResult.ArtistID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get wanted albums: %v", err)
|
||||||
|
}
|
||||||
|
if len(wantedAlbums) == 0 {
|
||||||
|
t.Error("expected albums to be added to wanted_albums after bulk monitor")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Step5_BulkUnmonitorArtistAlbums", func(t *testing.T) {
|
||||||
|
resp, err := env.PUT("/api/artists/"+syncResult.ArtistID+"/albums/monitor", map[string]any{
|
||||||
|
"monitored": false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
resp.AssertStatus(t, 200)
|
||||||
|
|
||||||
|
wantedAlbums, err := env.GetWantedAlbumsByArtist(ctx, syncResult.ArtistID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get wanted albums: %v", err)
|
||||||
|
}
|
||||||
|
if len(wantedAlbums) != 0 {
|
||||||
|
t.Errorf("expected no wanted albums after bulk unmonitor, got %d", len(wantedAlbums))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAlbumSearch_Flow covers section 2.2 of FLOWS.md:
|
||||||
|
// 1. User triggers search for specific album
|
||||||
|
// 2. Query all configured indexers (Torznab)
|
||||||
|
// 3. Filter results: check blocklist, check quality against profile
|
||||||
|
// 4. Rank results: FLAC preference, seeder count
|
||||||
|
// 5. Return ranked list for manual selection or auto-grab best
|
||||||
|
func TestAlbumSearch_Flow(t *testing.T) {
|
||||||
|
env := testutil.NewTestEnv(t)
|
||||||
|
defer env.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
artistName := "Radiohead"
|
||||||
|
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": "OK Computer",
|
||||||
|
"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"`
|
||||||
|
Results []struct {
|
||||||
|
AlbumID string `json:"album_id"`
|
||||||
|
AlbumTitle string `json:"album_title"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
if err := syncResp.DecodeJSON(&syncResult); err != nil {
|
||||||
|
t.Fatalf("failed to decode sync response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(syncResult.Results) == 0 {
|
||||||
|
t.Skip("no albums synced for test")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
env.CleanupArtistByName(context.Background(), artistName)
|
||||||
|
env.CleanupBlocklist(context.Background())
|
||||||
|
})
|
||||||
|
|
||||||
|
albums, err := env.GetAlbumsByArtistForeignID(ctx, syncResult.ArtistID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get albums: %v", err)
|
||||||
|
}
|
||||||
|
testAlbumID := albums[0]["id"].(string)
|
||||||
|
|
||||||
|
t.Run("Step1_SearchAlbum", func(t *testing.T) {
|
||||||
|
resp, err := env.POST("/api/albums/"+testAlbumID+"/search", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
resp.AssertStatus(t, 200)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
AlbumID string `json:"album_id"`
|
||||||
|
AlbumTitle string `json:"album_title"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
Results []struct {
|
||||||
|
GUID string `json:"guid"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
DownloadURL string `json:"download_url"`
|
||||||
|
Size uint64 `json:"size"`
|
||||||
|
Seeders *int `json:"seeders"`
|
||||||
|
Quality string `json:"quality"`
|
||||||
|
Indexer string `json:"indexer"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
} `json:"results"`
|
||||||
|
TotalResults int `json:"total_results"`
|
||||||
|
}
|
||||||
|
if err := resp.DecodeJSON(&result); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.AlbumID != testAlbumID {
|
||||||
|
t.Errorf("expected album_id=%s, got %s", testAlbumID, result.AlbumID)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("found %d results for album search", result.TotalResults)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Step2_SearchResults_RankedByQualityAndSeeders", func(t *testing.T) {
|
||||||
|
resp, err := env.POST("/api/albums/"+testAlbumID+"/search", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
resp.AssertStatus(t, 200)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Results []struct {
|
||||||
|
Quality string `json:"quality"`
|
||||||
|
Seeders *int `json:"seeders"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
if err := resp.DecodeJSON(&result); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Results) < 2 {
|
||||||
|
t.Skip("need at least 2 results to verify ranking")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 1; i < len(result.Results); i++ {
|
||||||
|
if result.Results[i].Score > result.Results[i-1].Score {
|
||||||
|
t.Errorf("results not sorted by score: result[%d].Score=%f > result[%d].Score=%f",
|
||||||
|
i, result.Results[i].Score, i-1, result.Results[i-1].Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Step3_BlocklistedResults_Filtered", func(t *testing.T) {
|
||||||
|
searchResp, err := env.POST("/api/albums/"+testAlbumID+"/search", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("search request failed: %v", err)
|
||||||
|
}
|
||||||
|
searchResp.AssertStatus(t, 200)
|
||||||
|
|
||||||
|
var searchResult struct {
|
||||||
|
Results []struct {
|
||||||
|
GUID string `json:"guid"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Indexer string `json:"indexer"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
if err := searchResp.DecodeJSON(&searchResult); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(searchResult.Results) == 0 {
|
||||||
|
t.Skip("no results to test blocklist filtering")
|
||||||
|
}
|
||||||
|
|
||||||
|
blockedGUID := searchResult.Results[0].GUID
|
||||||
|
blockedTitle := searchResult.Results[0].Title
|
||||||
|
|
||||||
|
blockResp, err := env.POST("/api/blocklist", map[string]any{
|
||||||
|
"album_id": testAlbumID,
|
||||||
|
"source_title": blockedTitle,
|
||||||
|
"guid": blockedGUID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("blocklist request failed: %v", err)
|
||||||
|
}
|
||||||
|
blockResp.AssertStatus(t, 200)
|
||||||
|
|
||||||
|
searchResp2, err := env.POST("/api/albums/"+testAlbumID+"/search", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second search request failed: %v", err)
|
||||||
|
}
|
||||||
|
searchResp2.AssertStatus(t, 200)
|
||||||
|
|
||||||
|
var searchResult2 struct {
|
||||||
|
Results []struct {
|
||||||
|
GUID string `json:"guid"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
if err := searchResp2.DecodeJSON(&searchResult2); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range searchResult2.Results {
|
||||||
|
if r.GUID == blockedGUID {
|
||||||
|
t.Errorf("blocklisted result (GUID=%s) should not appear in search results", blockedGUID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArtistSearch_Flow covers section 2.3 of FLOWS.md:
|
||||||
|
// 1. Search all monitored albums for an artist in one batch
|
||||||
|
// 2. For each monitored album: run album search flow
|
||||||
|
func TestArtistSearch_Flow(t *testing.T) {
|
||||||
|
env := testutil.NewTestEnv(t)
|
||||||
|
defer env.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
artistName := "Massive Attack"
|
||||||
|
if err := env.CleanupArtistByName(ctx, artistName); err != nil {
|
||||||
|
t.Fatalf("cleanup failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := env.CleanupWantedAlbums(ctx); err != nil {
|
||||||
|
t.Fatalf("cleanup wanted_albums failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
if err := syncResp.DecodeJSON(&syncResult); err != nil {
|
||||||
|
t.Fatalf("failed to decode sync response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
env.CleanupArtistByName(context.Background(), artistName)
|
||||||
|
env.CleanupWantedAlbums(context.Background())
|
||||||
|
})
|
||||||
|
|
||||||
|
albums, err := env.GetAlbumsByArtistForeignID(ctx, syncResult.ArtistID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get albums: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
monitoredCount := 0
|
||||||
|
for _, album := range albums {
|
||||||
|
if album["monitored"].(bool) {
|
||||||
|
monitoredCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("artist has %d/%d monitored albums", monitoredCount, len(albums))
|
||||||
|
|
||||||
|
t.Run("Step1_SearchAllMonitoredAlbums", func(t *testing.T) {
|
||||||
|
resp, err := env.POST("/api/artists/"+syncResult.ArtistID+"/search", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
resp.AssertStatus(t, 200)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
ArtistID string `json:"artist_id"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
AlbumsSearched int `json:"albums_searched"`
|
||||||
|
Results []struct {
|
||||||
|
AlbumID string `json:"album_id"`
|
||||||
|
AlbumTitle string `json:"album_title"`
|
||||||
|
ResultsCount int `json:"results_count"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
if err := resp.DecodeJSON(&result); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.ArtistID != syncResult.ArtistID {
|
||||||
|
t.Errorf("expected artist_id=%s, got %s", syncResult.ArtistID, result.ArtistID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.AlbumsSearched != monitoredCount {
|
||||||
|
t.Errorf("expected albums_searched=%d (monitored count), got %d",
|
||||||
|
monitoredCount, result.AlbumsSearched)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Results) != monitoredCount {
|
||||||
|
t.Errorf("expected %d album results, got %d", monitoredCount, len(result.Results))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("searched %d albums, got results for each", result.AlbumsSearched)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Step2_OnlyMonitoredAlbumsSearched", func(t *testing.T) {
|
||||||
|
if len(albums) < 2 {
|
||||||
|
t.Skip("need at least 2 albums to test selective monitoring")
|
||||||
|
}
|
||||||
|
|
||||||
|
unmonitorID := albums[0]["id"].(string)
|
||||||
|
_, err := env.PUT("/api/albums/"+unmonitorID, map[string]any{
|
||||||
|
"monitored": false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to unmonitor album: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := env.POST("/api/artists/"+syncResult.ArtistID+"/search", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
resp.AssertStatus(t, 200)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
AlbumsSearched int `json:"albums_searched"`
|
||||||
|
Results []struct {
|
||||||
|
AlbumID string `json:"album_id"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
if err := resp.DecodeJSON(&result); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range result.Results {
|
||||||
|
if r.AlbumID == unmonitorID {
|
||||||
|
t.Errorf("unmonitored album %s should not be searched", unmonitorID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedSearched := monitoredCount - 1
|
||||||
|
if result.AlbumsSearched != expectedSearched {
|
||||||
|
t.Errorf("expected albums_searched=%d after unmonitoring one, got %d",
|
||||||
|
expectedSearched, result.AlbumsSearched)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlbum_GetById(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
syncResp, err := env.POST("/api/sync", map[string]any{
|
||||||
|
"artist": artistName,
|
||||||
|
"album": "Selected Ambient Works",
|
||||||
|
"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"`
|
||||||
|
}
|
||||||
|
if err := syncResp.DecodeJSON(&syncResult); err != nil {
|
||||||
|
t.Fatalf("failed to decode sync response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
env.CleanupArtistByName(context.Background(), artistName)
|
||||||
|
})
|
||||||
|
|
||||||
|
albums, err := env.GetAlbumsByArtistForeignID(ctx, syncResult.ArtistID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get albums: %v", err)
|
||||||
|
}
|
||||||
|
if len(albums) == 0 {
|
||||||
|
t.Skip("no albums synced")
|
||||||
|
}
|
||||||
|
|
||||||
|
testAlbumID := albums[0]["id"].(string)
|
||||||
|
|
||||||
|
resp, err := env.GET("/api/albums/" + testAlbumID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
resp.AssertStatus(t, 200)
|
||||||
|
|
||||||
|
var album struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Monitored bool `json:"monitored"`
|
||||||
|
}
|
||||||
|
if err := resp.DecodeJSON(&album); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if album.ID != testAlbumID {
|
||||||
|
t.Errorf("expected id=%s, got %s", testAlbumID, album.ID)
|
||||||
|
}
|
||||||
|
if album.Title == "" {
|
||||||
|
t.Error("expected non-empty title")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlbum_NotFound(t *testing.T) {
|
||||||
|
env := testutil.NewTestEnv(t)
|
||||||
|
defer env.Close()
|
||||||
|
|
||||||
|
resp, err := env.GET("/api/albums/00000000-0000-0000-0000-000000000000")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
resp.AssertStatus(t, 404)
|
||||||
|
}
|
||||||
@@ -274,6 +274,93 @@ func (e *TestEnv) CountAlbumsByArtist(ctx context.Context, foreignArtistID strin
|
|||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAlbumByID retrieves an album by its UUID.
|
||||||
|
func (e *TestEnv) GetAlbumByID(ctx context.Context, albumID string) (map[string]any, error) {
|
||||||
|
var id, title string
|
||||||
|
var foreignAlbumID, albumType *string
|
||||||
|
var releaseDate *time.Time
|
||||||
|
var monitored bool
|
||||||
|
|
||||||
|
err := e.DB.QueryRow(ctx, `
|
||||||
|
SELECT id, foreign_album_id, title, album_type, release_date, monitored
|
||||||
|
FROM albums WHERE id = $1
|
||||||
|
`, albumID).Scan(&id, &foreignAlbumID, &title, &albumType, &releaseDate, &monitored)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]any{
|
||||||
|
"id": id,
|
||||||
|
"foreign_album_id": foreignAlbumID,
|
||||||
|
"title": title,
|
||||||
|
"album_type": albumType,
|
||||||
|
"monitored": monitored,
|
||||||
|
}
|
||||||
|
if releaseDate != nil {
|
||||||
|
result["release_date"] = releaseDate.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountWantedAlbums returns the number of entries in wanted_albums.
|
||||||
|
func (e *TestEnv) CountWantedAlbums(ctx context.Context) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := e.DB.QueryRow(ctx, "SELECT COUNT(*) FROM wanted_albums").Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAlbumWanted checks if an album is in the wanted_albums table.
|
||||||
|
func (e *TestEnv) IsAlbumWanted(ctx context.Context, albumID string) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
err := e.DB.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM wanted_albums WHERE album_id = $1
|
||||||
|
`, albumID).Scan(&count)
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWantedAlbumsByArtist returns wanted album IDs for an artist.
|
||||||
|
func (e *TestEnv) GetWantedAlbumsByArtist(ctx context.Context, foreignArtistID string) ([]string, error) {
|
||||||
|
rows, err := e.DB.Query(ctx, `
|
||||||
|
SELECT wa.album_id::text FROM wanted_albums wa
|
||||||
|
JOIN albums a ON wa.album_id = a.id
|
||||||
|
JOIN artist_metadata am ON a.artist_metadata_id = am.id
|
||||||
|
WHERE am.foreign_artist_id = $1
|
||||||
|
`, foreignArtistID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var ids []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountBlocklistEntries returns the number of entries in blocklist.
|
||||||
|
func (e *TestEnv) CountBlocklistEntries(ctx context.Context) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := e.DB.QueryRow(ctx, "SELECT COUNT(*) FROM blocklist").Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupWantedAlbums removes all wanted_albums entries (for test cleanup).
|
||||||
|
func (e *TestEnv) CleanupWantedAlbums(ctx context.Context) error {
|
||||||
|
_, err := e.DB.Exec(ctx, "DELETE FROM wanted_albums")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupBlocklist removes all blocklist entries (for test cleanup).
|
||||||
|
func (e *TestEnv) CleanupBlocklist(ctx context.Context) error {
|
||||||
|
_, err := e.DB.Exec(ctx, "DELETE FROM blocklist")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// GetAlbumsByArtistForeignID retrieves albums for an artist by foreign artist ID.
|
// GetAlbumsByArtistForeignID retrieves albums for an artist by foreign artist ID.
|
||||||
func (e *TestEnv) GetAlbumsByArtistForeignID(ctx context.Context, foreignArtistID string) ([]map[string]any, error) {
|
func (e *TestEnv) GetAlbumsByArtistForeignID(ctx context.Context, foreignArtistID string) ([]map[string]any, error) {
|
||||||
rows, err := e.DB.Query(ctx, `
|
rows, err := e.DB.Query(ctx, `
|
||||||
|
|||||||
Reference in New Issue
Block a user