This commit is contained in:
Alexander
2026-04-29 17:29:58 +02:00
parent 3ecc6aee62
commit 945aab82c2
24 changed files with 2038 additions and 822 deletions
+5 -774
View File
@@ -5,796 +5,27 @@ import (
"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
IndexerService *services.IndexerService
TorrentService *services.TorrentService
MetadataClient *metadata.Client
DB *database.DB
StorageBasePath string
}
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)