WIP
This commit is contained in:
+5
-774
@@ -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)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/fujin/music-agregator/internal/services"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/fujin/music-agregator/internal/database"
|
||||
"github.com/fujin/music-agregator/internal/services"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
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) 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) 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) 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) 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)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/fujin/music-agregator/internal/indexer"
|
||||
)
|
||||
|
||||
func (h *Handlers) ListIndexers(w http.ResponseWriter, r *http.Request) {
|
||||
indexers := h.IndexerService.GetIndexers(r.Context())
|
||||
writeJSON(w, http.StatusOK, indexers)
|
||||
}
|
||||
|
||||
func (h *Handlers) SearchIndexers(w http.ResponseWriter, r *http.Request) {
|
||||
var req 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"`
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/fujin/music-agregator/internal/services"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
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 (h *Handlers) GetJobStatus(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 job ID")
|
||||
return
|
||||
}
|
||||
|
||||
status, err := services.GetJobStatus(r.Context(), h.DB, h.TorrentService, id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "job not found")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
func (h *Handlers) ImportQueueItem(w http.ResponseWriter, r *http.Request) {
|
||||
if h.DB == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "database not connected")
|
||||
return
|
||||
}
|
||||
|
||||
if h.StorageBasePath == "" {
|
||||
writeError(w, http.StatusServiceUnavailable, "storage not configured")
|
||||
return
|
||||
}
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := parseUUID(idStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid ID")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := services.ImportCompletedDownload(r.Context(), id, h.StorageBasePath, h.DB, h.TorrentService)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/fujin/music-agregator/internal/services"
|
||||
)
|
||||
|
||||
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) 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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (h *Handlers) AddTorrent(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
URL string `json:"url"`
|
||||
SavePath *string `json:"save_path,omitempty"`
|
||||
}
|
||||
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"})
|
||||
}
|
||||
|
||||
func (h *Handlers) RemoveTorrent(w http.ResponseWriter, r *http.Request) {
|
||||
hash := chi.URLParam(r, "hash")
|
||||
|
||||
var req struct {
|
||||
DeleteFiles bool `json:"delete_files"`
|
||||
}
|
||||
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"})
|
||||
}
|
||||
@@ -69,6 +69,7 @@ func NewRouter(h *Handlers) *chi.Mux {
|
||||
r.Put("/{id}", h.UpdateQueueItem)
|
||||
r.Delete("/{id}", h.DeleteQueueItem)
|
||||
r.Post("/{id}/blocklist", h.BlocklistQueueItem)
|
||||
r.Post("/{id}/import", h.ImportQueueItem)
|
||||
})
|
||||
|
||||
r.Route("/library", func(r chi.Router) {
|
||||
@@ -76,6 +77,8 @@ func NewRouter(h *Handlers) *chi.Mux {
|
||||
r.Get("/albums", h.ListLibraryAlbums)
|
||||
r.Get("/stats", h.LibraryStats)
|
||||
})
|
||||
|
||||
r.Get("/job/{id}", h.GetJobStatus)
|
||||
})
|
||||
|
||||
return r
|
||||
|
||||
@@ -13,6 +13,11 @@ type Config struct {
|
||||
Metadata MetadataConfig `yaml:"metadata"`
|
||||
Indexers []IndexerConfig `yaml:"indexers"`
|
||||
Torrent TorrentConfig `yaml:"torrent"`
|
||||
Storage StorageConfig `yaml:"storage"`
|
||||
}
|
||||
|
||||
type StorageConfig struct {
|
||||
BasePath string `yaml:"base_path"`
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
@@ -88,5 +93,9 @@ func Load(path string) (*Config, error) {
|
||||
cfg.Torrent.SavePath = "/tmp/downloads"
|
||||
}
|
||||
|
||||
if cfg.Storage.BasePath == "" {
|
||||
cfg.Storage.BasePath = "/music"
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -718,6 +718,11 @@ func (db *DB) UpdateDownloadQueueProgress(ctx context.Context, id uuid.UUID, pro
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) UpdateDownloadQueueHash(ctx context.Context, id uuid.UUID, hash string) error {
|
||||
_, err := db.pool.Exec(ctx, `UPDATE download_queue SET torrent_hash = $1 WHERE id = $2`, hash, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) DeleteDownloadQueueItem(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := db.pool.Exec(ctx, `DELETE FROM download_queue WHERE id = $1`, id)
|
||||
return err
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/fujin/music-agregator/internal/database"
|
||||
"github.com/fujin/music-agregator/internal/indexer"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type RankedSearchResult struct {
|
||||
@@ -30,11 +31,16 @@ func SearchAlbum(
|
||||
db *database.DB,
|
||||
indexerService *IndexerService,
|
||||
) (*AlbumSearchResult, error) {
|
||||
log.Info().Str("album_id", albumID.String()).Msg("[ALBUM_SEARCH] starting search")
|
||||
|
||||
album, err := db.GetAlbumDetailByID(ctx, albumID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("album_id", albumID.String()).Msg("[ALBUM_SEARCH] album not found")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Info().Str("artist", album.ArtistName).Str("album", album.Title).Msg("[ALBUM_SEARCH] searching for album")
|
||||
|
||||
var year *uint32
|
||||
if album.ReleaseDate != nil {
|
||||
y := uint32(album.ReleaseDate.Year())
|
||||
@@ -51,13 +57,18 @@ func SearchAlbum(
|
||||
|
||||
results, err := indexerService.Search(ctx, criteria, nil)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[ALBUM_SEARCH] indexer search failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Info().Int("raw_results", len(results)).Msg("[ALBUM_SEARCH] got raw results from indexers")
|
||||
|
||||
var rankedResults []RankedSearchResult
|
||||
var blockedCount int
|
||||
for _, r := range results {
|
||||
blocked, _ := db.IsBlocklisted(ctx, r.Title, r.Infohash)
|
||||
if blocked {
|
||||
blockedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -71,10 +82,30 @@ func SearchAlbum(
|
||||
})
|
||||
}
|
||||
|
||||
if blockedCount > 0 {
|
||||
log.Info().Int("blocked", blockedCount).Msg("[ALBUM_SEARCH] filtered blocklisted results")
|
||||
}
|
||||
|
||||
sort.Slice(rankedResults, func(i, j int) bool {
|
||||
return rankedResults[i].Score > rankedResults[j].Score
|
||||
})
|
||||
|
||||
if len(rankedResults) > 0 {
|
||||
best := rankedResults[0]
|
||||
seeders := 0
|
||||
if best.Seeders != nil {
|
||||
seeders = *best.Seeders
|
||||
}
|
||||
log.Info().
|
||||
Str("title", best.Title).
|
||||
Str("quality", best.Quality).
|
||||
Float64("score", best.Score).
|
||||
Int("seeders", seeders).
|
||||
Msg("[ALBUM_SEARCH] best result")
|
||||
}
|
||||
|
||||
log.Info().Int("total_results", len(rankedResults)).Msg("[ALBUM_SEARCH] search completed")
|
||||
|
||||
return &AlbumSearchResult{
|
||||
AlbumID: albumID.String(),
|
||||
AlbumTitle: album.Title,
|
||||
|
||||
+160
-16
@@ -1,13 +1,20 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"github.com/fujin/music-agregator/internal/database"
|
||||
"github.com/fujin/music-agregator/internal/indexer"
|
||||
"github.com/fujin/music-agregator/internal/metadata"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -36,6 +43,7 @@ type AlbumSyncResult struct {
|
||||
DownloadStatus *DownloadStatus `json:"download_status,omitempty"`
|
||||
TorrentHash *string `json:"torrent_hash,omitempty"`
|
||||
Indexer *string `json:"indexer,omitempty"`
|
||||
JobID *string `json:"job_id,omitempty"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
@@ -53,6 +61,18 @@ type downloadResult struct {
|
||||
torrentHash *string
|
||||
indexer *string
|
||||
err *string
|
||||
queueID *string
|
||||
}
|
||||
|
||||
type downloadContext struct {
|
||||
artistName string
|
||||
albumTitle string
|
||||
year *uint32
|
||||
artistID *uuid.UUID
|
||||
albumID *uuid.UUID
|
||||
indexerService *IndexerService
|
||||
torrentService *TorrentService
|
||||
db *database.DB
|
||||
}
|
||||
|
||||
func Sync(
|
||||
@@ -153,7 +173,7 @@ func Sync(
|
||||
}
|
||||
|
||||
var downloadStatus *DownloadStatus
|
||||
var torrentHash, indexerName, dlError *string
|
||||
var torrentHash, indexerName, dlError, jobID *string
|
||||
|
||||
if options.Download {
|
||||
var year *uint32
|
||||
@@ -167,11 +187,38 @@ func Sync(
|
||||
}
|
||||
}
|
||||
|
||||
dlResult := downloadAlbum(ctx, artist.Name, album.Title, year, indexerService, torrentService)
|
||||
var artistUUID, albumUUID *uuid.UUID
|
||||
if artistMetadataID != nil {
|
||||
if id, err := uuid.Parse(*artistMetadataID); err == nil {
|
||||
artistUUID = &id
|
||||
if artistRow, err := db.GetArtistByForeignID(ctx, artist.Id); err == nil {
|
||||
artistUUID = &artistRow.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
if albumID, err := uuid.Parse(album.Id); err == nil {
|
||||
if albumRow, err := db.GetAlbumByID(ctx, albumID); err == nil {
|
||||
albumUUID = &albumRow.ID
|
||||
}
|
||||
}
|
||||
|
||||
dlCtx := &downloadContext{
|
||||
artistName: artist.Name,
|
||||
albumTitle: album.Title,
|
||||
year: year,
|
||||
artistID: artistUUID,
|
||||
albumID: albumUUID,
|
||||
indexerService: indexerService,
|
||||
torrentService: torrentService,
|
||||
db: db,
|
||||
}
|
||||
|
||||
dlResult := downloadAlbum(ctx, dlCtx)
|
||||
downloadStatus = &dlResult.status
|
||||
torrentHash = dlResult.torrentHash
|
||||
indexerName = dlResult.indexer
|
||||
dlError = dlResult.err
|
||||
jobID = dlResult.queueID
|
||||
|
||||
switch dlResult.status {
|
||||
case DownloadStatusAdded:
|
||||
@@ -190,6 +237,7 @@ func Sync(
|
||||
DownloadStatus: downloadStatus,
|
||||
TorrentHash: torrentHash,
|
||||
Indexer: indexerName,
|
||||
JobID: jobID,
|
||||
Error: dlError,
|
||||
})
|
||||
}
|
||||
@@ -206,39 +254,63 @@ func Sync(
|
||||
}, nil
|
||||
}
|
||||
|
||||
func downloadAlbum(
|
||||
ctx context.Context,
|
||||
artistName, albumTitle string,
|
||||
year *uint32,
|
||||
indexerService *IndexerService,
|
||||
torrentService *TorrentService,
|
||||
) downloadResult {
|
||||
albumStr := albumTitle
|
||||
func downloadAlbum(ctx context.Context, dlCtx *downloadContext) downloadResult {
|
||||
albumStr := dlCtx.albumTitle
|
||||
criteria := &indexer.MusicSearchCriteria{
|
||||
Artist: artistName,
|
||||
Artist: dlCtx.artistName,
|
||||
Album: &albumStr,
|
||||
Year: year,
|
||||
Year: dlCtx.year,
|
||||
Limit: 20,
|
||||
Offset: 0,
|
||||
}
|
||||
|
||||
searchResults, err := indexerService.Search(ctx, criteria, nil)
|
||||
log.Info().
|
||||
Str("artist", dlCtx.artistName).
|
||||
Str("album", dlCtx.albumTitle).
|
||||
Interface("year", dlCtx.year).
|
||||
Msg("[DOWNLOAD] searching indexers")
|
||||
|
||||
searchResults, err := dlCtx.indexerService.Search(ctx, criteria, nil)
|
||||
if err != nil {
|
||||
errStr := "indexer search failed: " + err.Error()
|
||||
log.Error().Err(err).Str("artist", dlCtx.artistName).Str("album", dlCtx.albumTitle).Msg("[DOWNLOAD] indexer search failed")
|
||||
return downloadResult{
|
||||
status: DownloadStatusFailed,
|
||||
err: &errStr,
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Int("results", len(searchResults)).
|
||||
Str("artist", dlCtx.artistName).
|
||||
Str("album", dlCtx.albumTitle).
|
||||
Msg("[DOWNLOAD] search completed")
|
||||
|
||||
if len(searchResults) == 0 {
|
||||
log.Warn().Str("artist", dlCtx.artistName).Str("album", dlCtx.albumTitle).Msg("[DOWNLOAD] no results found")
|
||||
return downloadResult{status: DownloadStatusNoResults}
|
||||
}
|
||||
|
||||
best := selectBestResult(searchResults)
|
||||
|
||||
if err := torrentService.AddTorrentURL(ctx, best.DownloadURL, nil); err != nil {
|
||||
errStr := "failed to add torrent: " + err.Error()
|
||||
seeders := 0
|
||||
if best.Seeders != nil {
|
||||
seeders = *best.Seeders
|
||||
}
|
||||
log.Info().
|
||||
Str("title", best.Title).
|
||||
Str("indexer", best.Indexer).
|
||||
Int("seeders", seeders).
|
||||
Uint64("size_bytes", best.Size).
|
||||
Interface("infohash", best.Infohash).
|
||||
Msg("[DOWNLOAD] selected best result")
|
||||
|
||||
log.Info().Str("url", best.DownloadURL).Msg("[DOWNLOAD] fetching torrent file")
|
||||
|
||||
torrent, err := fetchTorrentFile(ctx, best.DownloadURL)
|
||||
if err != nil {
|
||||
errStr := "failed to fetch torrent file: " + err.Error()
|
||||
log.Error().Err(err).Str("url", best.DownloadURL).Msg("[DOWNLOAD] failed to fetch torrent file")
|
||||
return downloadResult{
|
||||
status: DownloadStatusFailed,
|
||||
indexer: &best.Indexer,
|
||||
@@ -246,10 +318,41 @@ func downloadAlbum(
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Int("size_bytes", len(torrent.Data)).Str("infohash", torrent.InfoHash).Msg("[DOWNLOAD] adding torrent file to client")
|
||||
|
||||
if err := dlCtx.torrentService.AddTorrentFile(ctx, torrent.Data, nil); err != nil {
|
||||
errStr := "failed to add torrent: " + err.Error()
|
||||
log.Error().Err(err).Msg("[DOWNLOAD] failed to add torrent")
|
||||
return downloadResult{
|
||||
status: DownloadStatusFailed,
|
||||
indexer: &best.Indexer,
|
||||
err: &errStr,
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Str("indexer", best.Indexer).Str("hash", torrent.InfoHash).Msg("[DOWNLOAD] torrent added successfully")
|
||||
|
||||
infoHash := torrent.InfoHash
|
||||
|
||||
var queueIDStr *string
|
||||
if dlCtx.db != nil {
|
||||
title := dlCtx.artistName + " - " + dlCtx.albumTitle
|
||||
size := int64(best.Size)
|
||||
queueID, err := dlCtx.db.AddToDownloadQueue(ctx, title, size, &infoHash, &best.Indexer, dlCtx.albumID, dlCtx.artistID)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("title", title).Msg("[DOWNLOAD] failed to add to download queue")
|
||||
} else {
|
||||
idStr := queueID.String()
|
||||
queueIDStr = &idStr
|
||||
log.Info().Str("queue_id", idStr).Str("title", title).Str("hash", infoHash).Msg("[DOWNLOAD] added to download queue")
|
||||
}
|
||||
}
|
||||
|
||||
return downloadResult{
|
||||
status: DownloadStatusAdded,
|
||||
torrentHash: best.Infohash,
|
||||
torrentHash: &infoHash,
|
||||
indexer: &best.Indexer,
|
||||
queueID: queueIDStr,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +380,47 @@ func selectBestResult(results []indexer.SearchResult) *indexer.SearchResult {
|
||||
return best
|
||||
}
|
||||
|
||||
type torrentFile struct {
|
||||
Data []byte
|
||||
InfoHash string
|
||||
}
|
||||
|
||||
// fetchTorrentFile downloads a .torrent file from the given URL and extracts infohash.
|
||||
// This is necessary because the torrent client may be on a different network
|
||||
// (e.g., behind VPN) and cannot access the indexer directly.
|
||||
func fetchTorrentFile(ctx context.Context, url string) (*torrentFile, error) {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch torrent: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
mi, err := metainfo.Load(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse torrent: %w", err)
|
||||
}
|
||||
|
||||
hash := mi.HashInfoBytes().HexString()
|
||||
|
||||
return &torrentFile{Data: data, InfoHash: hash}, nil
|
||||
}
|
||||
|
||||
func parseUUID(s string) ([16]byte, error) {
|
||||
var id [16]byte
|
||||
s = strings.ReplaceAll(s, "-", "")
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/fujin/music-agregator/internal/database"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type ImportResult struct {
|
||||
QueueID string `json:"queue_id"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumTitle string `json:"album_title"`
|
||||
TargetPath string `json:"target_path"`
|
||||
FilesCopied int `json:"files_copied"`
|
||||
TotalSize int64 `json:"total_size"`
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
|
||||
func ImportCompletedDownload(
|
||||
ctx context.Context,
|
||||
queueID uuid.UUID,
|
||||
basePath string,
|
||||
db *database.DB,
|
||||
torrentService *TorrentService,
|
||||
) (*ImportResult, error) {
|
||||
log.Info().Str("queue_id", queueID.String()).Str("base_path", basePath).Msg("[IMPORT] starting import")
|
||||
|
||||
item, err := db.GetDownloadQueueItem(ctx, queueID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("queue_id", queueID.String()).Msg("[IMPORT] queue item not found")
|
||||
return nil, fmt.Errorf("queue item not found: %w", err)
|
||||
}
|
||||
|
||||
log.Info().Str("title", item.Title).Str("status", item.Status).Msg("[IMPORT] found queue item")
|
||||
|
||||
if item.Status != "completed" && item.Status != "seeding" {
|
||||
log.Error().Str("status", item.Status).Msg("[IMPORT] download not completed")
|
||||
return nil, fmt.Errorf("download not completed, status: %s", item.Status)
|
||||
}
|
||||
|
||||
if item.TorrentHash == nil {
|
||||
log.Error().Msg("[IMPORT] no torrent hash for queue item")
|
||||
return nil, fmt.Errorf("no torrent hash for queue item")
|
||||
}
|
||||
|
||||
log.Info().Str("hash", *item.TorrentHash).Msg("[IMPORT] fetching torrent info")
|
||||
torrent, err := torrentService.GetTorrent(ctx, *item.TorrentHash)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("hash", *item.TorrentHash).Msg("[IMPORT] torrent not found")
|
||||
return nil, fmt.Errorf("torrent not found: %w", err)
|
||||
}
|
||||
|
||||
log.Info().Str("name", torrent.Name).Str("save_path", torrent.SavePath).Msg("[IMPORT] torrent info retrieved")
|
||||
|
||||
var artistName, albumTitle string
|
||||
if item.AlbumID != nil {
|
||||
album, err := db.GetAlbumDetailByID(ctx, *item.AlbumID)
|
||||
if err == nil {
|
||||
artistName = album.ArtistName
|
||||
albumTitle = album.Title
|
||||
log.Info().Str("artist", artistName).Str("album", albumTitle).Msg("[IMPORT] resolved from database")
|
||||
}
|
||||
}
|
||||
|
||||
if artistName == "" || albumTitle == "" {
|
||||
parts := strings.SplitN(item.Title, " - ", 2)
|
||||
if len(parts) == 2 {
|
||||
artistName = parts[0]
|
||||
albumTitle = parts[1]
|
||||
} else {
|
||||
artistName = "Unknown Artist"
|
||||
albumTitle = item.Title
|
||||
}
|
||||
log.Info().Str("artist", artistName).Str("album", albumTitle).Msg("[IMPORT] parsed from title")
|
||||
}
|
||||
|
||||
artistName = sanitizePath(artistName)
|
||||
albumTitle = sanitizePath(albumTitle)
|
||||
|
||||
targetDir := filepath.Join(basePath, artistName, albumTitle)
|
||||
log.Info().Str("target_dir", targetDir).Msg("[IMPORT] creating target directory")
|
||||
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
||||
log.Error().Err(err).Str("target_dir", targetDir).Msg("[IMPORT] failed to create target directory")
|
||||
return nil, fmt.Errorf("failed to create target directory: %w", err)
|
||||
}
|
||||
|
||||
sourcePath := filepath.Join(torrent.SavePath, torrent.Name)
|
||||
log.Info().Str("source_path", sourcePath).Msg("[IMPORT] checking source path")
|
||||
|
||||
var filesCopied int
|
||||
var totalSize int64
|
||||
var copiedFiles []string
|
||||
|
||||
sourceInfo, err := os.Stat(sourcePath)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("source_path", sourcePath).Msg("[IMPORT] source path not found")
|
||||
return nil, fmt.Errorf("source path not found: %w", err)
|
||||
}
|
||||
|
||||
if sourceInfo.IsDir() {
|
||||
log.Info().Str("source_path", sourcePath).Msg("[IMPORT] source is directory, walking files")
|
||||
err = filepath.Walk(sourcePath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !isAudioFile(info.Name()) {
|
||||
log.Debug().Str("file", info.Name()).Msg("[IMPORT] skipping non-audio file")
|
||||
return nil
|
||||
}
|
||||
|
||||
relPath, _ := filepath.Rel(sourcePath, path)
|
||||
targetPath := filepath.Join(targetDir, relPath)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info().Str("src", path).Str("dst", targetPath).Msg("[IMPORT] copying file")
|
||||
if err := copyFile(path, targetPath); err != nil {
|
||||
log.Warn().Err(err).Str("file", path).Msg("[IMPORT] failed to copy file")
|
||||
return nil
|
||||
}
|
||||
|
||||
filesCopied++
|
||||
totalSize += info.Size()
|
||||
copiedFiles = append(copiedFiles, relPath)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[IMPORT] failed to copy files")
|
||||
return nil, fmt.Errorf("failed to copy files: %w", err)
|
||||
}
|
||||
} else {
|
||||
if isAudioFile(sourceInfo.Name()) {
|
||||
targetPath := filepath.Join(targetDir, sourceInfo.Name())
|
||||
log.Info().Str("src", sourcePath).Str("dst", targetPath).Msg("[IMPORT] copying single file")
|
||||
if err := copyFile(sourcePath, targetPath); err != nil {
|
||||
log.Error().Err(err).Msg("[IMPORT] failed to copy file")
|
||||
return nil, fmt.Errorf("failed to copy file: %w", err)
|
||||
}
|
||||
filesCopied = 1
|
||||
totalSize = sourceInfo.Size()
|
||||
copiedFiles = append(copiedFiles, sourceInfo.Name())
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Int("files_copied", filesCopied).Int64("total_size", totalSize).Msg("[IMPORT] file copy completed")
|
||||
|
||||
log.Info().Msg("[IMPORT] updating queue status to imported")
|
||||
if err := db.UpdateDownloadQueueStatus(ctx, queueID, "imported", nil); err != nil {
|
||||
log.Warn().Err(err).Msg("[IMPORT] failed to update queue status to imported")
|
||||
}
|
||||
|
||||
if item.AlbumID != nil {
|
||||
log.Info().Msg("[IMPORT] removing from wanted albums")
|
||||
db.RemoveFromWantedAlbums(ctx, *item.AlbumID)
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("artist", artistName).
|
||||
Str("album", albumTitle).
|
||||
Str("target_path", targetDir).
|
||||
Int("files_copied", filesCopied).
|
||||
Msg("[IMPORT] import completed successfully")
|
||||
|
||||
return &ImportResult{
|
||||
QueueID: queueID.String(),
|
||||
ArtistName: artistName,
|
||||
AlbumTitle: albumTitle,
|
||||
TargetPath: targetDir,
|
||||
FilesCopied: filesCopied,
|
||||
TotalSize: totalSize,
|
||||
Files: copiedFiles,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var pathSanitizeRegex = regexp.MustCompile(`[<>:"/\\|?*]`)
|
||||
|
||||
func sanitizePath(s string) string {
|
||||
s = pathSanitizeRegex.ReplaceAllString(s, "_")
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
s = "Unknown"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func isAudioFile(name string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
audioExts := map[string]bool{
|
||||
".flac": true,
|
||||
".mp3": true,
|
||||
".m4a": true,
|
||||
".aac": true,
|
||||
".ogg": true,
|
||||
".opus": true,
|
||||
".wav": true,
|
||||
".wma": true,
|
||||
".alac": true,
|
||||
}
|
||||
return audioExts[ext]
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
sourceFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sourceFile.Close()
|
||||
|
||||
destFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
if _, err := io.Copy(destFile, sourceFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return destFile.Sync()
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/fujin/music-agregator/internal/config"
|
||||
"github.com/fujin/music-agregator/internal/indexer"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type IndexerService struct {
|
||||
@@ -31,7 +32,25 @@ func NewIndexerService(configs []config.IndexerConfig) (*IndexerService, error)
|
||||
indexers = append(indexers, idx)
|
||||
}
|
||||
|
||||
return &IndexerService{indexers: indexers}, nil
|
||||
svc := &IndexerService{indexers: indexers}
|
||||
svc.checkHealth(context.Background())
|
||||
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
func (s *IndexerService) checkHealth(ctx context.Context) {
|
||||
for _, idx := range s.indexers {
|
||||
if err := idx.TestConnection(ctx); err != nil {
|
||||
log.Warn().
|
||||
Str("indexer", idx.Name()).
|
||||
Err(err).
|
||||
Msg("[INDEXER] failed to connect to indexer")
|
||||
} else {
|
||||
log.Info().
|
||||
Str("indexer", idx.Name()).
|
||||
Msg("[INDEXER] connected successfully")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildTorznabURL(cfg config.IndexerConfig) string {
|
||||
@@ -54,18 +73,37 @@ func buildTorznabURL(cfg config.IndexerConfig) string {
|
||||
func (s *IndexerService) Search(ctx context.Context, criteria *indexer.MusicSearchCriteria, indexerName *string) ([]indexer.SearchResult, error) {
|
||||
var results []indexer.SearchResult
|
||||
|
||||
log.Info().
|
||||
Str("artist", criteria.Artist).
|
||||
Interface("album", criteria.Album).
|
||||
Interface("year", criteria.Year).
|
||||
Msg("[INDEXER] searching indexers")
|
||||
|
||||
for _, idx := range s.indexers {
|
||||
if indexerName != nil && idx.Name() != *indexerName {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debug().Str("indexer", idx.Name()).Msg("[INDEXER] querying indexer")
|
||||
|
||||
r, err := idx.Search(ctx, criteria)
|
||||
if err != nil {
|
||||
log.Warn().
|
||||
Str("indexer", idx.Name()).
|
||||
Err(err).
|
||||
Msg("[INDEXER] search failed")
|
||||
continue
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("indexer", idx.Name()).
|
||||
Int("results", len(r)).
|
||||
Msg("[INDEXER] search completed")
|
||||
|
||||
results = append(results, r...)
|
||||
}
|
||||
|
||||
log.Info().Int("total_results", len(results)).Msg("[INDEXER] search finished")
|
||||
return results, nil
|
||||
}
|
||||
|
||||
|
||||
+148
-7
@@ -2,10 +2,12 @@ package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/fujin/music-agregator/internal/database"
|
||||
"github.com/fujin/music-agregator/internal/torrent"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type QueueSyncResult struct {
|
||||
@@ -14,52 +16,106 @@ type QueueSyncResult struct {
|
||||
}
|
||||
|
||||
func SyncDownloadQueue(ctx context.Context, db *database.DB, torrentService *TorrentService) (*QueueSyncResult, error) {
|
||||
log.Info().Msg("[QUEUE_SYNC] starting queue sync")
|
||||
|
||||
if !torrentService.IsConfigured() {
|
||||
log.Warn().Msg("[QUEUE_SYNC] torrent service not configured, skipping")
|
||||
return &QueueSyncResult{}, nil
|
||||
}
|
||||
|
||||
torrents, err := torrentService.ListTorrents(ctx)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[QUEUE_SYNC] failed to list torrents")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Info().Int("torrent_count", len(torrents)).Msg("[QUEUE_SYNC] fetched torrents from client")
|
||||
|
||||
torrentMap := make(map[string]torrent.TorrentInfo)
|
||||
torrentByName := make(map[string]torrent.TorrentInfo)
|
||||
for _, t := range torrents {
|
||||
torrentMap[t.Hash] = t
|
||||
nameLower := strings.ToLower(t.Name)
|
||||
torrentByName[nameLower] = t
|
||||
log.Debug().
|
||||
Str("hash", t.Hash).
|
||||
Str("name", t.Name).
|
||||
Str("state", string(t.State)).
|
||||
Float64("progress", t.Progress).
|
||||
Msg("[QUEUE_SYNC] torrent info")
|
||||
}
|
||||
|
||||
queueItems, err := db.ListDownloadQueue(ctx, nil)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[QUEUE_SYNC] failed to list queue items")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Info().Int("queue_count", len(queueItems)).Msg("[QUEUE_SYNC] fetched queue items from database")
|
||||
|
||||
var synced, updated int
|
||||
for _, item := range queueItems {
|
||||
if item.TorrentHash == nil {
|
||||
var t torrent.TorrentInfo
|
||||
var exists bool
|
||||
|
||||
if item.TorrentHash != nil {
|
||||
t, exists = torrentMap[*item.TorrentHash]
|
||||
if !exists {
|
||||
log.Debug().Str("hash", *item.TorrentHash).Str("title", item.Title).Msg("[QUEUE_SYNC] torrent not found by hash")
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
titleLower := strings.ToLower(item.Title)
|
||||
for name, torr := range torrentByName {
|
||||
if strings.Contains(name, titleLower) || strings.Contains(titleLower, name) {
|
||||
t = torr
|
||||
exists = true
|
||||
hash := t.Hash
|
||||
if item.TorrentHash == nil {
|
||||
log.Info().Str("title", item.Title).Str("matched_name", t.Name).Str("hash", hash).Msg("[QUEUE_SYNC] matched by title, updating hash")
|
||||
if err := db.UpdateDownloadQueueHash(ctx, item.ID, hash); err != nil {
|
||||
log.Error().Err(err).Msg("[QUEUE_SYNC] failed to update hash")
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
log.Debug().Str("title", item.Title).Msg("[QUEUE_SYNC] no matching torrent found")
|
||||
continue
|
||||
}
|
||||
|
||||
synced++
|
||||
t, exists := torrentMap[*item.TorrentHash]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
newStatus := mapTorrentState(t.State)
|
||||
sizeLeft := int64(float64(item.Size) * (1 - t.Progress))
|
||||
|
||||
if newStatus != item.Status || item.Progress != float32(t.Progress) {
|
||||
log.Info().
|
||||
Str("title", item.Title).
|
||||
Str("old_status", item.Status).
|
||||
Str("new_status", newStatus).
|
||||
Float32("old_progress", item.Progress).
|
||||
Float64("new_progress", t.Progress).
|
||||
Msg("[QUEUE_SYNC] updating queue item")
|
||||
|
||||
if err := db.UpdateDownloadQueueProgress(ctx, item.ID, float32(t.Progress), sizeLeft, newStatus); err != nil {
|
||||
log.Error().Err(err).Str("title", item.Title).Msg("[QUEUE_SYNC] failed to update queue item")
|
||||
continue
|
||||
}
|
||||
updated++
|
||||
|
||||
if newStatus == "completed" && item.AlbumID != nil {
|
||||
log.Info().Str("title", item.Title).Msg("[QUEUE_SYNC] download completed, removing from wanted albums")
|
||||
db.RemoveFromWantedAlbums(ctx, *item.AlbumID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Int("synced", synced).Int("updated", updated).Msg("[QUEUE_SYNC] sync completed")
|
||||
return &QueueSyncResult{Synced: synced, Updated: updated}, nil
|
||||
}
|
||||
|
||||
@@ -83,27 +139,37 @@ func mapTorrentState(state torrent.TorrentState) string {
|
||||
}
|
||||
|
||||
func HandleFailedDownload(ctx context.Context, db *database.DB, queueID uuid.UUID, errorMessage string) error {
|
||||
log.Info().Str("queue_id", queueID.String()).Str("error", errorMessage).Msg("[FAILED_DOWNLOAD] handling failed download")
|
||||
|
||||
item, err := db.GetDownloadQueueItem(ctx, queueID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("queue_id", queueID.String()).Msg("[FAILED_DOWNLOAD] failed to get queue item")
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info().Str("title", item.Title).Msg("[FAILED_DOWNLOAD] marking as failed")
|
||||
if err := db.UpdateDownloadQueueStatus(ctx, queueID, "failed", &errorMessage); err != nil {
|
||||
log.Error().Err(err).Msg("[FAILED_DOWNLOAD] failed to update status")
|
||||
return err
|
||||
}
|
||||
|
||||
if item.ArtistID != nil && item.AlbumID != nil {
|
||||
log.Info().Str("title", item.Title).Msg("[FAILED_DOWNLOAD] adding to blocklist")
|
||||
if err := db.AddToBlocklist(ctx, *item.ArtistID, *item.AlbumID, item.Title, item.TorrentHash, item.Indexer); err != nil {
|
||||
log.Error().Err(err).Msg("[FAILED_DOWNLOAD] failed to add to blocklist")
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if item.AlbumID != nil {
|
||||
log.Info().Str("title", item.Title).Msg("[FAILED_DOWNLOAD] re-adding to wanted albums for retry")
|
||||
if err := db.AddToWantedAlbums(ctx, *item.AlbumID); err != nil {
|
||||
log.Error().Err(err).Msg("[FAILED_DOWNLOAD] failed to add to wanted albums")
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Str("title", item.Title).Msg("[FAILED_DOWNLOAD] handling complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -112,12 +178,78 @@ type BlocklistResult struct {
|
||||
Removed bool `json:"removed"`
|
||||
}
|
||||
|
||||
func BlocklistAndRemove(ctx context.Context, db *database.DB, torrentService *TorrentService, queueID uuid.UUID) (*BlocklistResult, error) {
|
||||
item, err := db.GetDownloadQueueItem(ctx, queueID)
|
||||
type JobStatus struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Progress float32 `json:"progress"`
|
||||
Size int64 `json:"size"`
|
||||
SizeLeft int64 `json:"size_left"`
|
||||
TorrentHash *string `json:"torrent_hash,omitempty"`
|
||||
Indexer *string `json:"indexer,omitempty"`
|
||||
ErrorMessage *string `json:"error_message,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
CompletedAt *string `json:"completed_at,omitempty"`
|
||||
}
|
||||
|
||||
func GetJobStatus(ctx context.Context, db *database.DB, torrentService *TorrentService, jobID uuid.UUID) (*JobStatus, error) {
|
||||
log.Info().Str("job_id", jobID.String()).Msg("[JOB_STATUS] fetching job status")
|
||||
|
||||
item, err := db.GetDownloadQueueItem(ctx, jobID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("job_id", jobID.String()).Msg("[JOB_STATUS] job not found")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
status := &JobStatus{
|
||||
ID: item.ID.String(),
|
||||
Title: item.Title,
|
||||
Status: item.Status,
|
||||
Progress: item.Progress,
|
||||
Size: item.Size,
|
||||
SizeLeft: item.SizeLeft,
|
||||
TorrentHash: item.TorrentHash,
|
||||
Indexer: item.Indexer,
|
||||
ErrorMessage: item.ErrorMessage,
|
||||
CreatedAt: item.AddedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
|
||||
if item.CompletedAt != nil {
|
||||
completedStr := item.CompletedAt.Format("2006-01-02T15:04:05Z07:00")
|
||||
status.CompletedAt = &completedStr
|
||||
}
|
||||
|
||||
if (item.Status == "downloading" || item.Status == "queued") && item.TorrentHash != nil && torrentService.IsConfigured() {
|
||||
log.Debug().Str("hash", *item.TorrentHash).Msg("[JOB_STATUS] fetching torrent progress")
|
||||
torrent, err := torrentService.GetTorrent(ctx, *item.TorrentHash)
|
||||
if err == nil {
|
||||
status.Progress = float32(torrent.Progress)
|
||||
status.SizeLeft = int64(float64(item.Size) * (1 - torrent.Progress))
|
||||
status.Status = mapTorrentState(torrent.State)
|
||||
log.Info().
|
||||
Str("status", status.Status).
|
||||
Float32("progress", status.Progress).
|
||||
Msg("[JOB_STATUS] updated from torrent client")
|
||||
} else {
|
||||
log.Warn().Err(err).Str("hash", *item.TorrentHash).Msg("[JOB_STATUS] failed to get torrent info")
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Str("status", status.Status).Float32("progress", status.Progress).Msg("[JOB_STATUS] returning status")
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func BlocklistAndRemove(ctx context.Context, db *database.DB, torrentService *TorrentService, queueID uuid.UUID) (*BlocklistResult, error) {
|
||||
log.Info().Str("queue_id", queueID.String()).Msg("[BLOCKLIST] starting blocklist and remove")
|
||||
|
||||
item, err := db.GetDownloadQueueItem(ctx, queueID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("queue_id", queueID.String()).Msg("[BLOCKLIST] failed to get queue item")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Info().Str("title", item.Title).Interface("torrent_hash", item.TorrentHash).Msg("[BLOCKLIST] processing item")
|
||||
|
||||
result := &BlocklistResult{}
|
||||
|
||||
if item.ArtistID != nil {
|
||||
@@ -125,23 +257,32 @@ func BlocklistAndRemove(ctx context.Context, db *database.DB, torrentService *To
|
||||
if albumID == nil {
|
||||
albumID = &uuid.Nil
|
||||
}
|
||||
log.Info().Str("title", item.Title).Msg("[BLOCKLIST] adding to blocklist")
|
||||
if err := db.AddToBlocklist(ctx, *item.ArtistID, *albumID, item.Title, item.TorrentHash, item.Indexer); err == nil {
|
||||
result.Blocklisted = true
|
||||
log.Info().Str("title", item.Title).Msg("[BLOCKLIST] added to blocklist")
|
||||
} else {
|
||||
log.Warn().Err(err).Str("title", item.Title).Msg("[BLOCKLIST] failed to add to blocklist")
|
||||
}
|
||||
}
|
||||
|
||||
if item.TorrentHash != nil && torrentService.IsConfigured() {
|
||||
log.Info().Str("hash", *item.TorrentHash).Msg("[BLOCKLIST] removing torrent from client")
|
||||
torrentService.RemoveTorrent(ctx, *item.TorrentHash, true)
|
||||
}
|
||||
|
||||
log.Info().Str("title", item.Title).Msg("[BLOCKLIST] deleting from queue")
|
||||
if err := db.DeleteDownloadQueueItem(ctx, queueID); err != nil {
|
||||
log.Error().Err(err).Msg("[BLOCKLIST] failed to delete queue item")
|
||||
return nil, err
|
||||
}
|
||||
result.Removed = true
|
||||
|
||||
if item.AlbumID != nil {
|
||||
log.Info().Str("title", item.Title).Msg("[BLOCKLIST] re-adding album to wanted list")
|
||||
db.AddToWantedAlbums(ctx, *item.AlbumID)
|
||||
}
|
||||
|
||||
log.Info().Bool("blocklisted", result.Blocklisted).Bool("removed", result.Removed).Msg("[BLOCKLIST] completed")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -96,3 +96,10 @@ func (s *TorrentService) ResumeTorrent(ctx context.Context, hash string) error {
|
||||
func (s *TorrentService) IsConfigured() bool {
|
||||
return s.client != nil
|
||||
}
|
||||
|
||||
func (s *TorrentService) GetStubClient() *torrent.StubClient {
|
||||
if stub, ok := s.client.(*torrent.StubClient); ok {
|
||||
return stub
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type QBittorrentClient struct {
|
||||
@@ -54,7 +56,7 @@ func (c *QBittorrentClient) apiURL(path string) string {
|
||||
|
||||
func (c *QBittorrentClient) mapState(state string) TorrentState {
|
||||
switch state {
|
||||
case "downloading", "forcedDL", "metaDL", "allocating":
|
||||
case "downloading", "forcedDL", "metaDL", "allocating", "stalledDL":
|
||||
return StateDownloading
|
||||
case "uploading", "forcedUP", "stalledUP":
|
||||
return StateSeeding
|
||||
@@ -209,6 +211,8 @@ func (c *QBittorrentClient) AddTorrentURL(ctx context.Context, torrentURL string
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debug().Str("url", torrentURL).Msg("[QBITTORRENT] adding torrent URL")
|
||||
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
w.WriteField("urls", torrentURL)
|
||||
@@ -225,15 +229,27 @@ func (c *QBittorrentClient) AddTorrentURL(ctx context.Context, torrentURL string
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[QBITTORRENT] request failed")
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
bodyStr := strings.TrimSpace(string(body))
|
||||
|
||||
log.Debug().Int("status", resp.StatusCode).Str("body", bodyStr).Msg("[QBITTORRENT] add torrent response")
|
||||
|
||||
if !statusOK(resp.StatusCode) {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("%w: %s", ErrInvalidRequest, string(body))
|
||||
log.Error().Int("status", resp.StatusCode).Str("body", bodyStr).Msg("[QBITTORRENT] add torrent failed")
|
||||
return fmt.Errorf("%w: %s", ErrInvalidRequest, bodyStr)
|
||||
}
|
||||
|
||||
if bodyStr == "Fails." {
|
||||
log.Error().Str("url", torrentURL).Msg("[QBITTORRENT] torrent add rejected")
|
||||
return fmt.Errorf("qBittorrent rejected torrent: %s", torrentURL)
|
||||
}
|
||||
|
||||
log.Info().Msg("[QBITTORRENT] torrent added successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+145
-8
@@ -2,6 +2,8 @@ package torrent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
@@ -11,19 +13,25 @@ import (
|
||||
type StubClient struct {
|
||||
logPath string
|
||||
savePath string
|
||||
mu sync.Mutex
|
||||
mu sync.RWMutex
|
||||
logMu sync.Mutex
|
||||
torrents map[string]*TorrentInfo
|
||||
}
|
||||
|
||||
func NewStubClient(logPath, savePath string) *StubClient {
|
||||
return &StubClient{
|
||||
logPath: logPath,
|
||||
savePath: savePath,
|
||||
torrents: make(map[string]*TorrentInfo),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *StubClient) log(format string, args ...any) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.logPath == "" {
|
||||
return
|
||||
}
|
||||
c.logMu.Lock()
|
||||
defer c.logMu.Unlock()
|
||||
|
||||
f, err := os.OpenFile(c.logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
@@ -47,13 +55,29 @@ func (c *StubClient) Disconnect(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (c *StubClient) ListTorrents(ctx context.Context) ([]TorrentInfo, error) {
|
||||
c.log("LIST_TORRENTS")
|
||||
return []TorrentInfo{}, nil
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
c.log("LIST_TORRENTS count=%d", len(c.torrents))
|
||||
|
||||
result := make([]TorrentInfo, 0, len(c.torrents))
|
||||
for _, t := range c.torrents {
|
||||
result = append(result, *t)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *StubClient) GetTorrent(ctx context.Context, hash string) (*TorrentInfo, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
c.log("GET_TORRENT hash=%s", hash)
|
||||
return nil, ErrTorrentNotFound
|
||||
|
||||
t, ok := c.torrents[hash]
|
||||
if !ok {
|
||||
return nil, ErrTorrentNotFound
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (c *StubClient) AddTorrentURL(ctx context.Context, url string, savePath *string) error {
|
||||
@@ -61,7 +85,24 @@ func (c *StubClient) AddTorrentURL(ctx context.Context, url string, savePath *st
|
||||
if savePath != nil {
|
||||
path = *savePath
|
||||
}
|
||||
c.log("ADD_TORRENT_URL url=%s save_path=%s", url, path)
|
||||
|
||||
hash := generateHashFromURL(url)
|
||||
name := "Torrent-" + hash[:8]
|
||||
|
||||
c.mu.Lock()
|
||||
c.torrents[hash] = &TorrentInfo{
|
||||
Hash: hash,
|
||||
Name: name,
|
||||
Size: 500 * 1024 * 1024,
|
||||
Progress: 0,
|
||||
DownloadSpeed: 0,
|
||||
UploadSpeed: 0,
|
||||
State: StateQueued,
|
||||
SavePath: path,
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
c.log("ADD_TORRENT_URL url=%s hash=%s save_path=%s", url, hash, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -70,21 +111,117 @@ func (c *StubClient) AddTorrentFile(ctx context.Context, data []byte, savePath *
|
||||
if savePath != nil {
|
||||
path = *savePath
|
||||
}
|
||||
c.log("ADD_TORRENT_FILE size=%d save_path=%s", len(data), path)
|
||||
|
||||
hash := generateHashFromData(data)
|
||||
name := "Torrent-" + hash[:8]
|
||||
|
||||
c.mu.Lock()
|
||||
c.torrents[hash] = &TorrentInfo{
|
||||
Hash: hash,
|
||||
Name: name,
|
||||
Size: uint64(len(data) * 100),
|
||||
Progress: 0,
|
||||
DownloadSpeed: 0,
|
||||
UploadSpeed: 0,
|
||||
State: StateQueued,
|
||||
SavePath: path,
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
c.log("ADD_TORRENT_FILE size=%d hash=%s save_path=%s", len(data), hash, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *StubClient) RemoveTorrent(ctx context.Context, hash string, deleteFiles bool) error {
|
||||
c.mu.Lock()
|
||||
delete(c.torrents, hash)
|
||||
c.mu.Unlock()
|
||||
|
||||
c.log("REMOVE_TORRENT hash=%s delete_files=%t", hash, deleteFiles)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *StubClient) PauseTorrent(ctx context.Context, hash string) error {
|
||||
c.mu.Lock()
|
||||
if t, ok := c.torrents[hash]; ok {
|
||||
t.State = StatePaused
|
||||
t.DownloadSpeed = 0
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
c.log("PAUSE_TORRENT hash=%s", hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *StubClient) ResumeTorrent(ctx context.Context, hash string) error {
|
||||
c.mu.Lock()
|
||||
if t, ok := c.torrents[hash]; ok {
|
||||
if t.Progress < 1.0 {
|
||||
t.State = StateDownloading
|
||||
} else {
|
||||
t.State = StateSeeding
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
c.log("RESUME_TORRENT hash=%s", hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *StubClient) SetTorrentState(hash string, state TorrentState, progress float64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if t, ok := c.torrents[hash]; ok {
|
||||
t.State = state
|
||||
t.Progress = progress
|
||||
if state == StateSeeding {
|
||||
t.Progress = 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *StubClient) SetTorrentName(hash, name string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if t, ok := c.torrents[hash]; ok {
|
||||
t.Name = name
|
||||
}
|
||||
}
|
||||
|
||||
func (c *StubClient) AddTorrentDirect(info TorrentInfo) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.torrents[info.Hash] = &info
|
||||
}
|
||||
|
||||
func (c *StubClient) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.torrents = make(map[string]*TorrentInfo)
|
||||
}
|
||||
|
||||
func (c *StubClient) GetAllTorrents() map[string]*TorrentInfo {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
result := make(map[string]*TorrentInfo, len(c.torrents))
|
||||
for k, v := range c.torrents {
|
||||
copy := *v
|
||||
result[k] = ©
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func generateHashFromURL(url string) string {
|
||||
h := sha1.New()
|
||||
h.Write([]byte(url))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func generateHashFromData(data []byte) string {
|
||||
h := sha1.New()
|
||||
h.Write(data)
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user