Publish download events from PollDownloadWorker to event stream

PollDownloadWorker now publishes status/result/error events to both the
EventBus (for live UI updates via SubscribeEvents) and AlbumEventRepository
(for DB persistence and replay on reconnect).

- Add AlbumID, Quality, WorkflowRunID to PollDownloadArgs
- Add EventBus and AlbumEvents fields to PollDownloadWorker
- Publish status events with progress % on each poll tick
- Publish result event on download completion
- Publish error events on torrent failure
- Thread workflowRunID through saveTorrentAndDownload for event correlation
- RecoverOrphanedDownloads populates AlbumID/Quality from download record

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Alexander
2026-05-20 14:57:25 +02:00
parent b5849d6ad1
commit b5b175a8e9
3 changed files with 77 additions and 14 deletions
+1 -1
View File
@@ -417,7 +417,7 @@ func (w *monitorWorkflow) run(ctx context.Context) error {
} }
if dbAlbum != nil { if dbAlbum != nil {
w.publisher.SetAlbumID(dbAlbum.ID) w.publisher.SetAlbumID(dbAlbum.ID)
w.service.saveTorrentAndDownload(ctx, dbAlbum.ID, best) w.service.saveTorrentAndDownload(ctx, dbAlbum.ID, w.workflowRunID, best)
} else { } else {
log.Warn().Str("album_id", w.req.AlbumId).Msg("album not in DB after persist attempt, skipping torrent/download persistence") log.Warn().Str("album_id", w.req.AlbumId).Msg("album not in DB after persist attempt, skipping torrent/download persistence")
} }
+5 -2
View File
@@ -589,7 +589,7 @@ func (service *MusicAgregatorService) MonitorAlbum(ctx context.Context, req *pb.
dbAlbum, _ = service.metadata.GetAlbumByExternalID(ctx, album.GetId()) dbAlbum, _ = service.metadata.GetAlbumByExternalID(ctx, album.GetId())
} }
if dbAlbum != nil { if dbAlbum != nil {
service.saveTorrentAndDownload(ctx, dbAlbum.ID, best) service.saveTorrentAndDownload(ctx, dbAlbum.ID, "", best)
} else { } else {
log.Warn().Str("album_id", req.GetAlbumId()).Msg("album not in DB after persist attempt, skipping torrent/download persistence") log.Warn().Str("album_id", req.GetAlbumId()).Msg("album not in DB after persist attempt, skipping torrent/download persistence")
} }
@@ -840,7 +840,7 @@ func (service *MusicAgregatorService) addToTorrentClient(best parsedItem) error
return nil return nil
} }
func (service *MusicAgregatorService) saveTorrentAndDownload(ctx context.Context, dbAlbumID string, best parsedItem) { func (service *MusicAgregatorService) saveTorrentAndDownload(ctx context.Context, dbAlbumID string, workflowRunID string, best parsedItem) {
quality := normalizeQuality(pb.QualityType_QUALITY_UNSPECIFIED, best.rel.BitDepth, best.rel.SampleRate) quality := normalizeQuality(pb.QualityType_QUALITY_UNSPECIFIED, best.rel.BitDepth, best.rel.SampleRate)
dbTorrent := &database.Torrent{ dbTorrent := &database.Torrent{
@@ -898,6 +898,9 @@ func (service *MusicAgregatorService) saveTorrentAndDownload(ctx context.Context
DownloadID: download.ID, DownloadID: download.ID,
TorrentHash: best.rel.InfoHash, TorrentHash: best.rel.InfoHash,
CheckInterval: 30 * time.Second, CheckInterval: 30 * time.Second,
AlbumID: dbAlbumID,
Quality: quality,
WorkflowRunID: workflowRunID,
}, &river.InsertOpts{ }, &river.InsertOpts{
ScheduledAt: time.Now().Add(30 * time.Second), ScheduledAt: time.Now().Add(30 * time.Second),
}) })
+71 -11
View File
@@ -2,6 +2,7 @@ package workers
import ( import (
"context" "context"
"fmt"
"time" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -10,6 +11,7 @@ import (
"homelab.lan/music-agregator/internal/analysis" "homelab.lan/music-agregator/internal/analysis"
"homelab.lan/music-agregator/internal/database" "homelab.lan/music-agregator/internal/database"
"homelab.lan/music-agregator/internal/eventbus"
"homelab.lan/music-agregator/internal/metadata" "homelab.lan/music-agregator/internal/metadata"
"homelab.lan/music-agregator/internal/musicfs" "homelab.lan/music-agregator/internal/musicfs"
"homelab.lan/music-agregator/internal/torrent" "homelab.lan/music-agregator/internal/torrent"
@@ -19,24 +21,29 @@ type PollDownloadArgs struct {
DownloadID string `json:"download_id"` DownloadID string `json:"download_id"`
TorrentHash string `json:"torrent_hash"` TorrentHash string `json:"torrent_hash"`
CheckInterval time.Duration `json:"check_interval"` CheckInterval time.Duration `json:"check_interval"`
AlbumID string `json:"album_id"`
Quality string `json:"quality"`
WorkflowRunID string `json:"workflow_run_id"`
} }
func (PollDownloadArgs) Kind() string { return "poll_download" } func (PollDownloadArgs) Kind() string { return "poll_download" }
type PollDownloadWorker struct { type PollDownloadWorker struct {
river.WorkerDefaults[PollDownloadArgs] river.WorkerDefaults[PollDownloadArgs]
TorrentClient torrent.TorrentClient TorrentClient torrent.TorrentClient
Downloads *database.DownloadRepository Downloads *database.DownloadRepository
DownloadFiles *database.DownloadFileRepository DownloadFiles *database.DownloadFileRepository
AlbumReleases *database.AlbumReleaseRepository AlbumReleases *database.AlbumReleaseRepository
TrackReleases *database.TrackReleaseRepository TrackReleases *database.TrackReleaseRepository
RiverClient *river.Client[pgx.Tx] RiverClient *river.Client[pgx.Tx]
PathMapper *torrent.PathMapper PathMapper *torrent.PathMapper
Analyzer *analysis.ReleaseAnalyzer Analyzer *analysis.ReleaseAnalyzer
MusicFSClient *musicfs.Client MusicFSClient *musicfs.Client
MusicFSOriginID string MusicFSOriginID string
MusicFSOriginRoot string MusicFSOriginRoot string
MetadataService *metadata.MetadataService MetadataService *metadata.MetadataService
EventBus *eventbus.EventBus
AlbumEvents *database.AlbumEventRepository
} }
func (w *PollDownloadWorker) Work(ctx context.Context, job *river.Job[PollDownloadArgs]) error { func (w *PollDownloadWorker) Work(ctx context.Context, job *river.Job[PollDownloadArgs]) error {
@@ -53,6 +60,7 @@ func (w *PollDownloadWorker) Work(ctx context.Context, job *river.Job[PollDownlo
if len(results) == 0 { if len(results) == 0 {
log.Warn().Str("hash", args.TorrentHash).Msg("torrent not found in client, marking failed") log.Warn().Str("hash", args.TorrentHash).Msg("torrent not found in client, marking failed")
w.Downloads.SetFailed(ctx, args.DownloadID, "torrent not found in client") w.Downloads.SetFailed(ctx, args.DownloadID, "torrent not found in client")
w.publishEvent(ctx, args, "error", "", "torrent not found in client")
return nil return nil
} }
@@ -65,6 +73,7 @@ func (w *PollDownloadWorker) Work(ctx context.Context, job *river.Job[PollDownlo
case t.State == "error": case t.State == "error":
log.Warn().Str("hash", args.TorrentHash).Str("state", t.State).Msg("torrent in error state") log.Warn().Str("hash", args.TorrentHash).Str("state", t.State).Msg("torrent in error state")
w.Downloads.SetFailed(ctx, args.DownloadID, "torrent error state") w.Downloads.SetFailed(ctx, args.DownloadID, "torrent error state")
w.publishEvent(ctx, args, "error", "", "torrent error state")
return nil return nil
default: default:
@@ -74,6 +83,8 @@ func (w *PollDownloadWorker) Work(ctx context.Context, job *river.Job[PollDownlo
Float64("progress", t.Progress*100). Float64("progress", t.Progress*100).
Int64("dlspeed", t.DlSpeed). Int64("dlspeed", t.DlSpeed).
Msg("download in progress") Msg("download in progress")
w.publishEvent(ctx, args, "status", "",
fmt.Sprintf("Downloading: %.1f%% ↓%s", t.Progress*100, formatSpeed(t.DlSpeed)))
return w.reschedule(ctx, args) return w.reschedule(ctx, args)
} }
} }
@@ -127,6 +138,8 @@ func (w *PollDownloadWorker) onCompleted(ctx context.Context, args PollDownloadA
w.syncAndEnrichMusicFS(ctx, args, contentPath) w.syncAndEnrichMusicFS(ctx, args, contentPath)
} }
w.publishEvent(ctx, args, "result", "MONITOR_STEP_COMPLETE", "Download completed")
return nil return nil
} }
@@ -207,6 +220,8 @@ func (w *PollDownloadWorker) RecoverOrphanedDownloads(ctx context.Context) {
DownloadID: d.ID, DownloadID: d.ID,
TorrentHash: d.QbitHash, TorrentHash: d.QbitHash,
CheckInterval: 30 * time.Second, CheckInterval: 30 * time.Second,
AlbumID: d.AlbumID,
Quality: d.Quality,
}, &river.InsertOpts{ }, &river.InsertOpts{
ScheduledAt: time.Now().Add(5 * time.Second), ScheduledAt: time.Now().Add(5 * time.Second),
UniqueOpts: river.UniqueOpts{ UniqueOpts: river.UniqueOpts{
@@ -220,3 +235,48 @@ func (w *PollDownloadWorker) RecoverOrphanedDownloads(ctx context.Context) {
} }
} }
} }
func (w *PollDownloadWorker) publishEvent(ctx context.Context, args PollDownloadArgs, eventType, step, message string) {
if w.EventBus == nil {
return
}
topic := args.AlbumID + ":" + args.Quality
var seq int64
if w.AlbumEvents != nil && args.AlbumID != "" {
event := &database.AlbumEvent{
WorkflowRunID: args.WorkflowRunID,
AlbumID: args.AlbumID,
EventType: eventType,
Step: step,
Message: message,
}
if err := w.AlbumEvents.Create(ctx, event); err != nil {
log.Error().Err(err).Msg("failed to persist download event")
} else {
seq = event.Seq
}
}
w.EventBus.Publish(topic, &eventbus.Event{
Seq: seq,
WorkflowRunID: args.WorkflowRunID,
AlbumID: args.AlbumID,
Quality: args.Quality,
EventType: eventType,
Step: step,
Message: message,
})
}
func formatSpeed(bytesPerSec int64) string {
switch {
case bytesPerSec >= 1<<20:
return fmt.Sprintf("%.1f MB/s", float64(bytesPerSec)/float64(1<<20))
case bytesPerSec >= 1<<10:
return fmt.Sprintf("%.0f KB/s", float64(bytesPerSec)/float64(1<<10))
default:
return fmt.Sprintf("%d B/s", bytesPerSec)
}
}