From c307c68d88736a817a099c7987c22655cbfa5127 Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 29 Apr 2026 13:34:20 +0200 Subject: [PATCH] feat: add album management endpoints (sections 2.1, 2.2, 2.3) --- internal/api/handlers.go | 206 +++++++++++ internal/api/router.go | 10 + internal/database/db.go | 191 ++++++++++ internal/services/album.go | 200 +++++++++++ testing/e2e/album_test.go | 581 +++++++++++++++++++++++++++++++ testing/e2e/testutil/testutil.go | 87 +++++ 6 files changed, 1275 insertions(+) create mode 100644 internal/services/album.go create mode 100644 testing/e2e/album_test.go diff --git a/internal/api/handlers.go b/internal/api/handlers.go index a0659ca..4b11500 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -9,6 +9,7 @@ import ( "github.com/fujin/music-agregator/internal/metadata" "github.com/fujin/music-agregator/internal/services" "github.com/go-chi/chi/v5" + "github.com/google/uuid" ) type Handlers struct { @@ -371,6 +372,211 @@ func (h *Handlers) EditArtist(w http.ResponseWriter, r *http.Request) { 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) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/internal/api/router.go b/internal/api/router.go index ca3651a..204575a 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -48,8 +48,18 @@ func NewRouter(h *Handlers) *chi.Mux { r.Put("/{id}", h.EditArtist) r.Post("/{id}/refresh", h.RefreshArtist) 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.Get("/artists", h.ListLibraryArtists) r.Get("/albums", h.ListLibraryAlbums) diff --git a/internal/database/db.go b/internal/database/db.go index 810f4d7..c0544db 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -416,3 +416,194 @@ func (db *DB) GetArtistMetadataByForeignID(ctx context.Context, foreignArtistID } 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 +} diff --git a/internal/services/album.go b/internal/services/album.go new file mode 100644 index 0000000..b8bb3bc --- /dev/null +++ b/internal/services/album.go @@ -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 +} diff --git a/testing/e2e/album_test.go b/testing/e2e/album_test.go new file mode 100644 index 0000000..8a46a12 --- /dev/null +++ b/testing/e2e/album_test.go @@ -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) +} diff --git a/testing/e2e/testutil/testutil.go b/testing/e2e/testutil/testutil.go index 364bae8..09f63a4 100644 --- a/testing/e2e/testutil/testutil.go +++ b/testing/e2e/testutil/testutil.go @@ -274,6 +274,93 @@ func (e *TestEnv) CountAlbumsByArtist(ctx context.Context, foreignArtistID strin 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. func (e *TestEnv) GetAlbumsByArtistForeignID(ctx context.Context, foreignArtistID string) ([]map[string]any, error) { rows, err := e.DB.Query(ctx, `