feat: add album management endpoints (sections 2.1, 2.2, 2.3)

This commit is contained in:
Alexander
2026-04-29 13:34:20 +02:00
parent ff49403fd5
commit c307c68d88
6 changed files with 1275 additions and 0 deletions
+206
View File
@@ -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)
+10
View File
@@ -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)
+191
View File
@@ -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
}
+200
View File
@@ -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
}