Refactor MonitorAlbumStream: EventPublisher interface, background workflows, DB-before-qBit save order

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-11 15:54:01 +02:00
parent 52e81faedd
commit 5a5660bf21
4 changed files with 643 additions and 146 deletions
+245 -7
View File
@@ -2,6 +2,8 @@ package internal
import (
"context"
"encoding/json"
"time"
"github.com/jackc/pgx/v5"
"github.com/riverqueue/river"
@@ -13,11 +15,14 @@ import (
pb "homelab.lan/music-agregator/gen/music_agregator/v1"
"homelab.lan/music-agregator/internal/config"
"homelab.lan/music-agregator/internal/database"
"homelab.lan/music-agregator/internal/eventbus"
"homelab.lan/music-agregator/internal/torrent"
)
type MusicAgregatorServer struct {
service *MusicAgregatorService
service *MusicAgregatorService
bus *eventbus.EventBus
registry *WorkflowRegistry
pb.UnimplementedMusicAgregatorServiceServer
}
@@ -27,13 +32,29 @@ func NewMusicAgregatorServer(cfg config.Config, riverClient *river.Client[pgx.Tx
log.Err(err).Msg("failed to create MusicAgregatorService")
return nil, err
}
bus := eventbus.New()
return &MusicAgregatorServer{
service: service,
service: service,
bus: bus,
registry: NewWorkflowRegistry(bus),
}, nil
}
func NewMusicAgregatorServerWithService(service *MusicAgregatorService) *MusicAgregatorServer {
return &MusicAgregatorServer{service: service}
bus := eventbus.New()
return &MusicAgregatorServer{
service: service,
bus: bus,
registry: NewWorkflowRegistry(bus),
}
}
func NewMusicAgregatorServerWithDeps(service *MusicAgregatorService, bus *eventbus.EventBus, registry *WorkflowRegistry) *MusicAgregatorServer {
return &MusicAgregatorServer{
service: service,
bus: bus,
registry: registry,
}
}
func (s *MusicAgregatorServer) GetArtists(ctx context.Context, req *pb.GetArtistsRequest) (*pb.GetArtistsResponse, error) {
@@ -59,16 +80,158 @@ func (s *MusicAgregatorServer) MonitorAlbumStream(stream pb.MusicAgregatorServic
return status.Error(codes.InvalidArgument, "first message must be StartMonitorRequest")
}
if startReq.Mode == pb.InteractionMode_INTERACTION_MODE_MANUAL {
return s.runManualWorkflow(stream, startReq)
}
return s.runAutomaticWorkflow(stream, startReq)
}
func (s *MusicAgregatorServer) runManualWorkflow(stream pb.MusicAgregatorService_MonitorAlbumStreamServer, startReq *pb.StartMonitorRequest) error {
ctx, cancel := context.WithCancel(stream.Context())
defer cancel()
workflow := newMonitorWorkflow(stream, startReq, s.service, cancel)
albumKey := startReq.AlbumId
quality := startReq.Quality.String()
topic := albumKey + ":" + quality
if startReq.Mode == pb.InteractionMode_INTERACTION_MODE_MANUAL {
go workflow.receiveDecisions(ctx)
dbPublisher := newDBEventPublisher("", quality, s.service.albumEvents, s.bus, topic)
publisher := newStreamEventPublisher(dbPublisher, stream)
workflow := &monitorWorkflow{
mode: startReq.Mode,
req: startReq,
service: s.service,
publisher: publisher,
stream: stream,
decisions: make(chan *pb.UserDecision, 1),
cancel: cancel,
}
return workflow.run(ctx)
go workflow.receiveDecisions(ctx)
err := workflow.run(ctx)
if ctx.Err() != nil {
cleanupCtx := context.Background()
workflow.cleanup(cleanupCtx)
if workflow.workflowRunID != "" {
s.service.workflowRuns.SetCancelled(cleanupCtx, workflow.workflowRunID)
}
} else if workflow.workflowRunID != "" {
if err != nil {
s.service.workflowRuns.SetFailed(context.Background(), workflow.workflowRunID, err.Error())
} else {
s.service.workflowRuns.SetCompleted(context.Background(), workflow.workflowRunID)
}
}
return err
}
func (s *MusicAgregatorServer) runAutomaticWorkflow(stream pb.MusicAgregatorService_MonitorAlbumStreamServer, startReq *pb.StartMonitorRequest) error {
albumKey := startReq.AlbumId
quality := startReq.Quality.String()
entry, created := s.registry.GetOrCreate(context.Background(), albumKey, quality)
sub, cleanup := s.bus.Subscribe(entry.Topic)
defer cleanup()
if created {
s.registry.WaitGroup().Add(1)
go func() {
defer s.registry.WaitGroup().Done()
defer s.registry.Remove(albumKey, quality)
publisher := newDBEventPublisher("", quality, s.service.albumEvents, s.bus, entry.Topic)
workflow := &monitorWorkflow{
mode: startReq.Mode,
req: startReq,
service: s.service,
publisher: publisher,
}
err := workflow.run(entry.Ctx)
if workflow.workflowRunID != "" {
if err != nil {
if entry.Ctx.Err() == context.Canceled {
s.service.workflowRuns.SetCancelled(context.Background(), workflow.workflowRunID)
} else {
s.service.workflowRuns.SetFailed(context.Background(), workflow.workflowRunID, err.Error())
}
} else {
s.service.workflowRuns.SetCompleted(context.Background(), workflow.workflowRunID)
}
}
}()
}
for {
select {
case <-sub.C:
for {
event, ok := sub.Ring.Pop()
if !ok {
break
}
if err := s.sendEventToStream(stream, event); err != nil {
return nil
}
if event.EventType == "result" || event.EventType == "error" {
return nil
}
}
case <-stream.Context().Done():
return nil
}
}
}
func (s *MusicAgregatorServer) sendEventToStream(stream pb.MusicAgregatorService_MonitorAlbumStreamServer, event *eventbus.Event) error {
resp := &pb.MonitorAlbumStreamResponse{}
step := pb.MonitorStep(pb.MonitorStep_value[event.Step])
switch event.EventType {
case "status":
status := &pb.StatusUpdate{Step: step, Message: event.Message}
switch v := event.Data.(type) {
case *pb.StreamAlbumInfo:
status.Data = &pb.StatusUpdate_AlbumInfo{AlbumInfo: v}
case *pb.TorrentList:
status.Data = &pb.StatusUpdate_Torrents{Torrents: v}
case *pb.ReleaseInfo:
status.Data = &pb.StatusUpdate_ReleaseInfo{ReleaseInfo: v}
}
resp.Message = &pb.MonitorAlbumStreamResponse_Status{Status: status}
case "error":
recoverable := false
if data, ok := event.Data.(map[string]bool); ok {
recoverable = data["recoverable"]
}
resp.Message = &pb.MonitorAlbumStreamResponse_Error{
Error: &pb.ErrorUpdate{FailedStep: step, Message: event.Message, Recoverable: recoverable},
}
case "result":
if result, ok := event.Data.(*pb.MonitorAlbumResponse); ok {
resp.Message = &pb.MonitorAlbumStreamResponse_Result{Result: result}
} else if event.Data != nil {
if jsonBytes, ok := event.Data.(json.RawMessage); ok {
var result pb.MonitorAlbumResponse
if err := json.Unmarshal(jsonBytes, &result); err == nil {
resp.Message = &pb.MonitorAlbumStreamResponse_Result{Result: &result}
}
}
}
}
return stream.Send(resp)
}
func (s *MusicAgregatorServer) AnalyzeAlbumRelease(ctx context.Context, req *pb.AnalyzeAlbumReleaseRequest) (*pb.AnalyzeAlbumReleaseResponse, error) {
@@ -86,3 +249,78 @@ func (s *MusicAgregatorServer) GetArtistAlbums(ctx context.Context, req *pb.GetA
func (s *MusicAgregatorServer) Register(server *grpc.Server) {
pb.RegisterMusicAgregatorServiceServer(server, s)
}
func (s *MusicAgregatorServer) SubscribeEvents(req *pb.SubscribeEventsRequest, stream pb.MusicAgregatorService_SubscribeEventsServer) error {
ctx := stream.Context()
sub, cleanup := s.bus.SubscribeGlobal()
defer cleanup()
if req.SinceSeq > 0 {
events, err := s.service.albumEvents.GetAfterSeq(ctx, req.SinceSeq)
if err == nil {
for _, e := range events {
if err := stream.Send(albumEventToProto(e)); err != nil {
return err
}
}
}
}
var lastSentSeq int64
if req.SinceSeq > 0 {
lastSentSeq = req.SinceSeq
}
for {
select {
case <-sub.C:
for {
event, ok := sub.Ring.Pop()
if !ok {
break
}
if event.Seq > lastSentSeq {
pbEvent := busEventToAlbumEvent(event)
if err := stream.Send(pbEvent); err != nil {
return nil
}
lastSentSeq = event.Seq
}
}
case <-ctx.Done():
return nil
}
}
}
func albumEventToProto(e *database.AlbumEvent) *pb.AlbumEvent {
return &pb.AlbumEvent{
Seq: e.Seq,
WorkflowRunId: e.WorkflowRunID,
AlbumId: e.AlbumID,
EventType: e.EventType,
Step: e.Step,
Message: e.Message,
DataJson: e.DataJSON,
TimestampMs: e.CreatedAt.UnixMilli(),
}
}
func busEventToAlbumEvent(e *eventbus.Event) *pb.AlbumEvent {
var dataJSON []byte
if e.Data != nil {
dataJSON, _ = json.Marshal(e.Data)
}
return &pb.AlbumEvent{
Seq: e.Seq,
WorkflowRunId: e.WorkflowRunID,
AlbumId: e.AlbumID,
Quality: e.Quality,
EventType: e.EventType,
Step: e.Step,
Message: e.Message,
DataJson: dataJSON,
TimestampMs: time.Now().UnixMilli(),
}
}