package api import ( "encoding/json" "net/http" "github.com/fujin/music-agregator/internal/database" "github.com/fujin/music-agregator/internal/indexer" "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 { IndexerService *services.IndexerService TorrentService *services.TorrentService MetadataClient *metadata.Client DB *database.DB } func (h *Handlers) Health(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (h *Handlers) ListIndexers(w http.ResponseWriter, r *http.Request) { indexers := h.IndexerService.GetIndexers(r.Context()) writeJSON(w, http.StatusOK, indexers) } type searchRequest struct { Artist string `json:"artist"` Album *string `json:"album,omitempty"` Year *uint32 `json:"year,omitempty"` Limit int `json:"limit,omitempty"` Offset int `json:"offset,omitempty"` } func (h *Handlers) SearchIndexers(w http.ResponseWriter, r *http.Request) { var req searchRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if req.Limit == 0 { req.Limit = 20 } criteria := &indexer.MusicSearchCriteria{ Artist: req.Artist, Album: req.Album, Year: req.Year, Limit: req.Limit, Offset: req.Offset, } results, err := h.IndexerService.Search(r.Context(), criteria, nil) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, results) } func (h *Handlers) ListTorrents(w http.ResponseWriter, r *http.Request) { torrents, err := h.TorrentService.ListTorrents(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, torrents) } func (h *Handlers) GetTorrent(w http.ResponseWriter, r *http.Request) { hash := chi.URLParam(r, "hash") torrent, err := h.TorrentService.GetTorrent(r.Context(), hash) if err != nil { writeError(w, http.StatusNotFound, err.Error()) return } writeJSON(w, http.StatusOK, torrent) } type addTorrentRequest struct { URL string `json:"url"` SavePath *string `json:"save_path,omitempty"` } func (h *Handlers) AddTorrent(w http.ResponseWriter, r *http.Request) { var req addTorrentRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if err := h.TorrentService.AddTorrentURL(r.Context(), req.URL, req.SavePath); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"status": "added"}) } type removeTorrentRequest struct { DeleteFiles bool `json:"delete_files"` } func (h *Handlers) RemoveTorrent(w http.ResponseWriter, r *http.Request) { hash := chi.URLParam(r, "hash") var req removeTorrentRequest json.NewDecoder(r.Body).Decode(&req) if err := h.TorrentService.RemoveTorrent(r.Context(), hash, req.DeleteFiles); err != nil { writeError(w, http.StatusNotFound, err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"status": "removed"}) } func (h *Handlers) PauseTorrent(w http.ResponseWriter, r *http.Request) { hash := chi.URLParam(r, "hash") if err := h.TorrentService.PauseTorrent(r.Context(), hash); err != nil { writeError(w, http.StatusNotFound, err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"status": "paused"}) } func (h *Handlers) ResumeTorrent(w http.ResponseWriter, r *http.Request) { hash := chi.URLParam(r, "hash") if err := h.TorrentService.ResumeTorrent(r.Context(), hash); err != nil { writeError(w, http.StatusNotFound, err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"status": "resumed"}) } func (h *Handlers) SearchArtists(w http.ResponseWriter, r *http.Request) { var req struct { Query string `json:"query"` Limit int32 `json:"limit,omitempty"` Offset int32 `json:"offset,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if req.Limit == 0 { req.Limit = 10 } result, err := h.MetadataClient.SearchArtists(r.Context(), req.Query, req.Limit, req.Offset) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, result) } func (h *Handlers) GetArtistAlbums(w http.ResponseWriter, r *http.Request) { artistID := chi.URLParam(r, "id") result, err := h.MetadataClient.GetArtistAlbums(r.Context(), artistID, 500, 0) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, result) } func (h *Handlers) Sync(w http.ResponseWriter, r *http.Request) { var req struct { Artist string `json:"artist"` Album *string `json:"album,omitempty"` Download *bool `json:"download,omitempty"` Store *bool `json:"store,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } download := true if req.Download != nil { download = *req.Download } store := true if req.Store != nil { store = *req.Store } options := services.SyncOptions{ Artist: req.Artist, Album: req.Album, Download: download, Store: store, } result, err := services.Sync(r.Context(), options, h.MetadataClient, h.IndexerService, h.TorrentService, h.DB) if err != nil { if _, ok := err.(*services.NotFoundError); ok { writeError(w, http.StatusNotFound, err.Error()) return } writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, result) } func (h *Handlers) ListLibraryArtists(w http.ResponseWriter, r *http.Request) { if h.DB == nil { writeError(w, http.StatusServiceUnavailable, "database not connected") return } artists, err := h.DB.ListArtists(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, artists) } func (h *Handlers) ListLibraryAlbums(w http.ResponseWriter, r *http.Request) { if h.DB == nil { writeError(w, http.StatusServiceUnavailable, "database not connected") return } albums, err := h.DB.ListAllAlbums(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, albums) } func (h *Handlers) LibraryStats(w http.ResponseWriter, r *http.Request) { if h.DB == nil { writeError(w, http.StatusServiceUnavailable, "database not connected") return } artistCount, err := h.DB.CountArtists(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } albumCount, err := h.DB.CountAlbums(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]int64{ "artists": artistCount, "albums": albumCount, }) } func (h *Handlers) RefreshArtist(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.RefreshArtist(r.Context(), artistID, h.MetadataClient, h.DB) if err != nil { if _, ok := err.(*services.NotFoundError); ok { writeError(w, http.StatusNotFound, err.Error()) return } writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, result) } func (h *Handlers) DeleteArtist(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 } deleted, err := h.DB.DeleteArtistByForeignID(r.Context(), artistID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if !deleted { writeError(w, http.StatusNotFound, "artist not found: "+artistID) return } writeJSON(w, http.StatusOK, map[string]any{ "deleted": true, "message": "artist and related data deleted", }) } func (h *Handlers) GetArtist(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 } artist, err := h.DB.GetArtistByForeignID(r.Context(), artistID) if err != nil { writeError(w, http.StatusNotFound, "artist not found: "+artistID) return } writeJSON(w, http.StatusOK, artist) } func (h *Handlers) EditArtist(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 update database.ArtistUpdate if err := json.NewDecoder(r.Body).Decode(&update); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } artist, err := h.DB.UpdateArtistByForeignID(r.Context(), artistID, update) if err != nil { writeError(w, http.StatusNotFound, "artist not found: "+artistID) return } 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 (h *Handlers) ListQueue(w http.ResponseWriter, r *http.Request) { if h.DB == nil { writeError(w, http.StatusServiceUnavailable, "database not connected") return } var status *string if s := r.URL.Query().Get("status"); s != "" { status = &s } items, err := h.DB.ListDownloadQueue(r.Context(), status) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{ "items": items, "total": len(items), }) } func (h *Handlers) GetQueueItem(w http.ResponseWriter, r *http.Request) { if h.DB == nil { writeError(w, http.StatusServiceUnavailable, "database not connected") return } idStr := chi.URLParam(r, "id") id, err := parseUUID(idStr) if err != nil { writeError(w, http.StatusBadRequest, "invalid ID") return } item, err := h.DB.GetDownloadQueueItem(r.Context(), id) if err != nil { writeError(w, http.StatusNotFound, "queue item not found") return } writeJSON(w, http.StatusOK, item) } func (h *Handlers) AddToQueue(w http.ResponseWriter, r *http.Request) { if h.DB == nil { writeError(w, http.StatusServiceUnavailable, "database not connected") return } var req struct { Title string `json:"title"` TorrentHash *string `json:"torrent_hash"` Size int64 `json:"size"` Indexer *string `json:"indexer"` AlbumID *string `json:"album_id"` ArtistID *string `json:"artist_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } var albumID, artistID *uuid.UUID if req.AlbumID != nil { if id, err := parseUUID(*req.AlbumID); err == nil { albumID = &id } } if req.ArtistID != nil { if id, err := parseUUID(*req.ArtistID); err == nil { artistID = &id } } id, err := h.DB.AddToDownloadQueue(r.Context(), req.Title, req.Size, req.TorrentHash, req.Indexer, albumID, artistID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } item, _ := h.DB.GetDownloadQueueItem(r.Context(), id) writeJSON(w, http.StatusOK, item) } func (h *Handlers) UpdateQueueItem(w http.ResponseWriter, r *http.Request) { if h.DB == nil { writeError(w, http.StatusServiceUnavailable, "database not connected") return } idStr := chi.URLParam(r, "id") id, err := parseUUID(idStr) if err != nil { writeError(w, http.StatusBadRequest, "invalid ID") return } var req struct { Status *string `json:"status"` ErrorMessage *string `json:"error_message"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if req.Status != nil { if *req.Status == "failed" && req.ErrorMessage != nil { services.HandleFailedDownload(r.Context(), h.DB, id, *req.ErrorMessage) } else { if err := h.DB.UpdateDownloadQueueStatus(r.Context(), id, *req.Status, req.ErrorMessage); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if *req.Status == "completed" { item, _ := h.DB.GetDownloadQueueItem(r.Context(), id) if item != nil && item.AlbumID != nil { h.DB.RemoveFromWantedAlbums(r.Context(), *item.AlbumID) } } } } item, err := h.DB.GetDownloadQueueItem(r.Context(), id) if err != nil { writeError(w, http.StatusNotFound, "queue item not found") return } writeJSON(w, http.StatusOK, item) } func (h *Handlers) DeleteQueueItem(w http.ResponseWriter, r *http.Request) { if h.DB == nil { writeError(w, http.StatusServiceUnavailable, "database not connected") return } idStr := chi.URLParam(r, "id") id, err := parseUUID(idStr) if err != nil { writeError(w, http.StatusBadRequest, "invalid ID") return } item, err := h.DB.GetDownloadQueueItem(r.Context(), id) if err != nil { writeError(w, http.StatusNotFound, "queue item not found") return } if item.TorrentHash != nil && h.TorrentService.IsConfigured() { h.TorrentService.RemoveTorrent(r.Context(), *item.TorrentHash, false) } if err := h.DB.DeleteDownloadQueueItem(r.Context(), id); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"deleted": true}) } func (h *Handlers) SyncQueue(w http.ResponseWriter, r *http.Request) { if h.DB == nil { writeError(w, http.StatusServiceUnavailable, "database not connected") return } result, err := services.SyncDownloadQueue(r.Context(), h.DB, h.TorrentService) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, result) } func (h *Handlers) BlocklistQueueItem(w http.ResponseWriter, r *http.Request) { if h.DB == nil { writeError(w, http.StatusServiceUnavailable, "database not connected") return } idStr := chi.URLParam(r, "id") id, err := parseUUID(idStr) if err != nil { writeError(w, http.StatusBadRequest, "invalid ID") return } result, err := services.BlocklistAndRemove(r.Context(), h.DB, h.TorrentService, id) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, result) } func (h *Handlers) QueueStats(w http.ResponseWriter, r *http.Request) { if h.DB == nil { writeError(w, http.StatusServiceUnavailable, "database not connected") return } stats, err := h.DB.GetDownloadQueueStats(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, stats) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, status int, message string) { writeJSON(w, status, map[string]string{"error": message}) }