feat: add download tracking endpoints (sections 4.1, 4.2, 4.3, 4.4)

This commit is contained in:
Alexander
2026-04-29 13:44:01 +02:00
parent c307c68d88
commit 3ecc6aee62
6 changed files with 999 additions and 0 deletions
+218
View File
@@ -577,6 +577,224 @@ 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)
+11
View File
@@ -60,6 +60,17 @@ func NewRouter(h *Handlers) *chi.Mux {
r.Post("/blocklist", h.AddToBlocklist)
r.Route("/queue", func(r chi.Router) {
r.Get("/", h.ListQueue)
r.Post("/", h.AddToQueue)
r.Post("/sync", h.SyncQueue)
r.Get("/stats", h.QueueStats)
r.Get("/{id}", h.GetQueueItem)
r.Put("/{id}", h.UpdateQueueItem)
r.Delete("/{id}", h.DeleteQueueItem)
r.Post("/{id}/blocklist", h.BlocklistQueueItem)
})
r.Route("/library", func(r chi.Router) {
r.Get("/artists", h.ListLibraryArtists)
r.Get("/albums", h.ListLibraryAlbums)
+152
View File
@@ -607,3 +607,155 @@ func (db *DB) GetArtistIDByAlbum(ctx context.Context, albumID uuid.UUID) (*uuid.
}
return &artistID, nil
}
type DownloadQueueRow struct {
ID uuid.UUID `json:"id"`
ArtistID *uuid.UUID `json:"artist_id"`
AlbumID *uuid.UUID `json:"album_id"`
DownloadID *string `json:"download_id"`
Title string `json:"title"`
Size int64 `json:"size"`
SizeLeft int64 `json:"size_left"`
Status string `json:"status"`
Progress float32 `json:"progress"`
ErrorMessage *string `json:"error_message"`
Protocol string `json:"protocol"`
Indexer *string `json:"indexer"`
DownloadClient *string `json:"download_client"`
TorrentHash *string `json:"torrent_hash"`
OutputPath *string `json:"output_path"`
AddedAt time.Time `json:"added_at"`
CompletedAt *time.Time `json:"completed_at"`
}
func (db *DB) AddToDownloadQueue(ctx context.Context, title string, size int64, torrentHash, indexer *string, albumID, artistID *uuid.UUID) (uuid.UUID, error) {
var id uuid.UUID
err := db.pool.QueryRow(ctx, `
INSERT INTO download_queue (title, size, torrent_hash, indexer, album_id, artist_id, status)
VALUES ($1, $2, $3, $4, $5, $6, 'queued')
RETURNING id
`, title, size, torrentHash, indexer, albumID, artistID).Scan(&id)
return id, err
}
func (db *DB) GetDownloadQueueItem(ctx context.Context, id uuid.UUID) (*DownloadQueueRow, error) {
var row DownloadQueueRow
err := db.pool.QueryRow(ctx, `
SELECT id, artist_id, album_id, download_id, title, size, size_left, status, progress,
error_message, protocol, indexer, download_client, torrent_hash, output_path, added_at, completed_at
FROM download_queue WHERE id = $1
`, id).Scan(&row.ID, &row.ArtistID, &row.AlbumID, &row.DownloadID, &row.Title, &row.Size,
&row.SizeLeft, &row.Status, &row.Progress, &row.ErrorMessage, &row.Protocol, &row.Indexer,
&row.DownloadClient, &row.TorrentHash, &row.OutputPath, &row.AddedAt, &row.CompletedAt)
if err != nil {
return nil, err
}
return &row, nil
}
func (db *DB) ListDownloadQueue(ctx context.Context, status *string) ([]DownloadQueueRow, error) {
var rows []DownloadQueueRow
var query string
var args []any
if status != nil {
query = `
SELECT id, artist_id, album_id, download_id, title, size, size_left, status, progress,
error_message, protocol, indexer, download_client, torrent_hash, output_path, added_at, completed_at
FROM download_queue WHERE status = $1 ORDER BY added_at DESC
`
args = []any{*status}
} else {
query = `
SELECT id, artist_id, album_id, download_id, title, size, size_left, status, progress,
error_message, protocol, indexer, download_client, torrent_hash, output_path, added_at, completed_at
FROM download_queue ORDER BY added_at DESC
`
}
dbRows, err := db.pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer dbRows.Close()
for dbRows.Next() {
var row DownloadQueueRow
err := dbRows.Scan(&row.ID, &row.ArtistID, &row.AlbumID, &row.DownloadID, &row.Title, &row.Size,
&row.SizeLeft, &row.Status, &row.Progress, &row.ErrorMessage, &row.Protocol, &row.Indexer,
&row.DownloadClient, &row.TorrentHash, &row.OutputPath, &row.AddedAt, &row.CompletedAt)
if err != nil {
return nil, err
}
rows = append(rows, row)
}
return rows, nil
}
func (db *DB) UpdateDownloadQueueStatus(ctx context.Context, id uuid.UUID, status string, errorMessage *string) error {
if status == "completed" {
_, err := db.pool.Exec(ctx, `
UPDATE download_queue SET status = $1, completed_at = NOW() WHERE id = $2
`, status, id)
return err
}
if errorMessage != nil {
_, err := db.pool.Exec(ctx, `
UPDATE download_queue SET status = $1, error_message = $2 WHERE id = $3
`, status, *errorMessage, id)
return err
}
_, err := db.pool.Exec(ctx, `
UPDATE download_queue SET status = $1 WHERE id = $2
`, status, id)
return err
}
func (db *DB) UpdateDownloadQueueProgress(ctx context.Context, id uuid.UUID, progress float32, sizeLeft int64, status string) error {
_, err := db.pool.Exec(ctx, `
UPDATE download_queue SET progress = $1, size_left = $2, status = $3 WHERE id = $4
`, progress, sizeLeft, status, 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
}
func (db *DB) GetDownloadQueueByTorrentHash(ctx context.Context, hash string) (*DownloadQueueRow, error) {
var row DownloadQueueRow
err := db.pool.QueryRow(ctx, `
SELECT id, artist_id, album_id, download_id, title, size, size_left, status, progress,
error_message, protocol, indexer, download_client, torrent_hash, output_path, added_at, completed_at
FROM download_queue WHERE torrent_hash = $1
`, hash).Scan(&row.ID, &row.ArtistID, &row.AlbumID, &row.DownloadID, &row.Title, &row.Size,
&row.SizeLeft, &row.Status, &row.Progress, &row.ErrorMessage, &row.Protocol, &row.Indexer,
&row.DownloadClient, &row.TorrentHash, &row.OutputPath, &row.AddedAt, &row.CompletedAt)
if err != nil {
return nil, err
}
return &row, nil
}
type DownloadQueueStats struct {
Total int64 `json:"total"`
Downloading int64 `json:"downloading"`
Queued int64 `json:"queued"`
Completed int64 `json:"completed"`
Failed int64 `json:"failed"`
}
func (db *DB) GetDownloadQueueStats(ctx context.Context) (*DownloadQueueStats, error) {
var stats DownloadQueueStats
err := db.pool.QueryRow(ctx, `
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE status = 'downloading') as downloading,
COUNT(*) FILTER (WHERE status = 'queued') as queued,
COUNT(*) FILTER (WHERE status = 'completed') as completed,
COUNT(*) FILTER (WHERE status = 'failed') as failed
FROM download_queue
`).Scan(&stats.Total, &stats.Downloading, &stats.Queued, &stats.Completed, &stats.Failed)
return &stats, err
}
+147
View File
@@ -0,0 +1,147 @@
package services
import (
"context"
"github.com/fujin/music-agregator/internal/database"
"github.com/fujin/music-agregator/internal/torrent"
"github.com/google/uuid"
)
type QueueSyncResult struct {
Synced int `json:"synced"`
Updated int `json:"updated"`
}
func SyncDownloadQueue(ctx context.Context, db *database.DB, torrentService *TorrentService) (*QueueSyncResult, error) {
if !torrentService.IsConfigured() {
return &QueueSyncResult{}, nil
}
torrents, err := torrentService.ListTorrents(ctx)
if err != nil {
return nil, err
}
torrentMap := make(map[string]torrent.TorrentInfo)
for _, t := range torrents {
torrentMap[t.Hash] = t
}
queueItems, err := db.ListDownloadQueue(ctx, nil)
if err != nil {
return nil, err
}
var synced, updated int
for _, item := range queueItems {
if item.TorrentHash == nil {
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) {
if err := db.UpdateDownloadQueueProgress(ctx, item.ID, float32(t.Progress), sizeLeft, newStatus); err != nil {
continue
}
updated++
if newStatus == "completed" && item.AlbumID != nil {
db.RemoveFromWantedAlbums(ctx, *item.AlbumID)
}
}
}
return &QueueSyncResult{Synced: synced, Updated: updated}, nil
}
func mapTorrentState(state torrent.TorrentState) string {
switch state {
case torrent.StateDownloading:
return "downloading"
case torrent.StateSeeding:
return "completed"
case torrent.StatePaused:
return "paused"
case torrent.StateQueued:
return "queued"
case torrent.StateChecking:
return "checking"
case torrent.StateError:
return "failed"
default:
return "queued"
}
}
func HandleFailedDownload(ctx context.Context, db *database.DB, queueID uuid.UUID, errorMessage string) error {
item, err := db.GetDownloadQueueItem(ctx, queueID)
if err != nil {
return err
}
if err := db.UpdateDownloadQueueStatus(ctx, queueID, "failed", &errorMessage); err != nil {
return err
}
if item.ArtistID != nil && item.AlbumID != nil {
if err := db.AddToBlocklist(ctx, *item.ArtistID, *item.AlbumID, item.Title, item.TorrentHash, item.Indexer); err != nil {
return err
}
}
if item.AlbumID != nil {
if err := db.AddToWantedAlbums(ctx, *item.AlbumID); err != nil {
return err
}
}
return nil
}
type BlocklistResult struct {
Blocklisted bool `json:"blocklisted"`
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)
if err != nil {
return nil, err
}
result := &BlocklistResult{}
if item.ArtistID != nil {
albumID := item.AlbumID
if albumID == nil {
albumID = &uuid.Nil
}
if err := db.AddToBlocklist(ctx, *item.ArtistID, *albumID, item.Title, item.TorrentHash, item.Indexer); err == nil {
result.Blocklisted = true
}
}
if item.TorrentHash != nil && torrentService.IsConfigured() {
torrentService.RemoveTorrent(ctx, *item.TorrentHash, true)
}
if err := db.DeleteDownloadQueueItem(ctx, queueID); err != nil {
return nil, err
}
result.Removed = true
if item.AlbumID != nil {
db.AddToWantedAlbums(ctx, *item.AlbumID)
}
return result, nil
}