Add MonitorAlbumStream bidirectional streaming RPC with automatic and manual interaction modes
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
pb "homelab.lan/music-agregator/gen/music_agregator/v1"
|
||||
"homelab.lan/music-agregator/internal/database"
|
||||
)
|
||||
|
||||
var MaxPromptTimeout = 300 * time.Second
|
||||
|
||||
type monitorWorkflow struct {
|
||||
stream pb.MusicAgregatorService_MonitorAlbumStreamServer
|
||||
mode pb.InteractionMode
|
||||
req *pb.StartMonitorRequest
|
||||
service *MusicAgregatorService
|
||||
decisions chan *pb.UserDecision
|
||||
cancel context.CancelFunc
|
||||
mu sync.Mutex
|
||||
promptID int
|
||||
}
|
||||
|
||||
func newMonitorWorkflow(
|
||||
stream pb.MusicAgregatorService_MonitorAlbumStreamServer,
|
||||
req *pb.StartMonitorRequest,
|
||||
service *MusicAgregatorService,
|
||||
cancel context.CancelFunc,
|
||||
) *monitorWorkflow {
|
||||
return &monitorWorkflow{
|
||||
stream: stream,
|
||||
mode: req.Mode,
|
||||
req: req,
|
||||
service: service,
|
||||
decisions: make(chan *pb.UserDecision, 1),
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *monitorWorkflow) sendStatus(step pb.MonitorStep, msg string) error {
|
||||
select {
|
||||
case <-w.stream.Context().Done():
|
||||
return w.stream.Context().Err()
|
||||
default:
|
||||
}
|
||||
return w.stream.Send(&pb.MonitorAlbumStreamResponse{
|
||||
Message: &pb.MonitorAlbumStreamResponse_Status{
|
||||
Status: &pb.StatusUpdate{Step: step, Message: msg},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (w *monitorWorkflow) sendStatusWithAlbumInfo(step pb.MonitorStep, msg string, info *pb.StreamAlbumInfo) error {
|
||||
select {
|
||||
case <-w.stream.Context().Done():
|
||||
return w.stream.Context().Err()
|
||||
default:
|
||||
}
|
||||
return w.stream.Send(&pb.MonitorAlbumStreamResponse{
|
||||
Message: &pb.MonitorAlbumStreamResponse_Status{
|
||||
Status: &pb.StatusUpdate{
|
||||
Step: step,
|
||||
Message: msg,
|
||||
Data: &pb.StatusUpdate_AlbumInfo{AlbumInfo: info},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (w *monitorWorkflow) sendStatusWithTorrents(step pb.MonitorStep, msg string, torrents *pb.TorrentList) error {
|
||||
select {
|
||||
case <-w.stream.Context().Done():
|
||||
return w.stream.Context().Err()
|
||||
default:
|
||||
}
|
||||
return w.stream.Send(&pb.MonitorAlbumStreamResponse{
|
||||
Message: &pb.MonitorAlbumStreamResponse_Status{
|
||||
Status: &pb.StatusUpdate{
|
||||
Step: step,
|
||||
Message: msg,
|
||||
Data: &pb.StatusUpdate_Torrents{Torrents: torrents},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (w *monitorWorkflow) sendStatusWithRelease(step pb.MonitorStep, msg string, release *pb.ReleaseInfo) error {
|
||||
select {
|
||||
case <-w.stream.Context().Done():
|
||||
return w.stream.Context().Err()
|
||||
default:
|
||||
}
|
||||
return w.stream.Send(&pb.MonitorAlbumStreamResponse{
|
||||
Message: &pb.MonitorAlbumStreamResponse_Status{
|
||||
Status: &pb.StatusUpdate{
|
||||
Step: step,
|
||||
Message: msg,
|
||||
Data: &pb.StatusUpdate_ReleaseInfo{ReleaseInfo: release},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (w *monitorWorkflow) sendError(step pb.MonitorStep, err error, recoverable bool) error {
|
||||
return w.stream.Send(&pb.MonitorAlbumStreamResponse{
|
||||
Message: &pb.MonitorAlbumStreamResponse_Error{
|
||||
Error: &pb.ErrorUpdate{
|
||||
FailedStep: step,
|
||||
Message: err.Error(),
|
||||
Recoverable: recoverable,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (w *monitorWorkflow) sendResult(result *pb.MonitorAlbumResponse) error {
|
||||
return w.stream.Send(&pb.MonitorAlbumStreamResponse{
|
||||
Message: &pb.MonitorAlbumStreamResponse_Result{Result: result},
|
||||
})
|
||||
}
|
||||
|
||||
func (w *monitorWorkflow) nextPromptID() string {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.promptID++
|
||||
return fmt.Sprintf("prompt-%d", w.promptID)
|
||||
}
|
||||
|
||||
func (w *monitorWorkflow) promptAndWait(ctx context.Context, prompt *pb.PromptForDecision) (*pb.UserDecision, error) {
|
||||
if w.mode == pb.InteractionMode_INTERACTION_MODE_AUTOMATIC {
|
||||
return w.defaultDecision(prompt), nil
|
||||
}
|
||||
|
||||
if err := w.stream.Send(&pb.MonitorAlbumStreamResponse{
|
||||
Message: &pb.MonitorAlbumStreamResponse_Prompt{Prompt: prompt},
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
timeout := time.Duration(prompt.TimeoutSeconds) * time.Second
|
||||
if timeout == 0 || timeout > MaxPromptTimeout {
|
||||
timeout = MaxPromptTimeout
|
||||
}
|
||||
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case decision := <-w.decisions:
|
||||
if decision.PromptId != prompt.PromptId {
|
||||
return nil, status.Error(codes.InvalidArgument, "prompt_id mismatch")
|
||||
}
|
||||
return decision, nil
|
||||
case <-timeoutCtx.Done():
|
||||
return w.defaultDecision(prompt), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (w *monitorWorkflow) defaultDecision(prompt *pb.PromptForDecision) *pb.UserDecision {
|
||||
decision := &pb.UserDecision{PromptId: prompt.PromptId}
|
||||
|
||||
switch prompt.Type {
|
||||
case pb.PromptType_PROMPT_TYPE_CONFIRM:
|
||||
decision.Decision = &pb.UserDecision_Confirm{
|
||||
Confirm: prompt.GetConfirm().GetDefaultValue(),
|
||||
}
|
||||
case pb.PromptType_PROMPT_TYPE_SELECT_ONE:
|
||||
decision.Decision = &pb.UserDecision_SelectedId{
|
||||
SelectedId: prompt.GetSelectOne().GetDefaultId(),
|
||||
}
|
||||
case pb.PromptType_PROMPT_TYPE_SELECT_MANY:
|
||||
decision.Decision = &pb.UserDecision_SelectedIds{
|
||||
SelectedIds: &pb.SelectedIds{Ids: prompt.GetSelectMany().GetDefaultIds()},
|
||||
}
|
||||
}
|
||||
|
||||
return decision
|
||||
}
|
||||
|
||||
func (w *monitorWorkflow) receiveDecisions(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
msg, err := w.stream.Recv()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if msg.GetCancel() != nil {
|
||||
w.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
if decision := msg.GetDecision(); decision != nil {
|
||||
select {
|
||||
case w.decisions <- decision:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *monitorWorkflow) run(ctx context.Context) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
w.sendStatus(pb.MonitorStep_MONITOR_STEP_FETCHING_METADATA, "Fetching album metadata...")
|
||||
|
||||
album, err := w.service.metadata.GetAlbum(ctx, w.req.AlbumId)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
log.Error().Err(err).Str("album_id", w.req.AlbumId).Msg("failed to get album")
|
||||
w.sendError(pb.MonitorStep_MONITOR_STEP_FETCHING_METADATA, err, false)
|
||||
return err
|
||||
}
|
||||
|
||||
artistName := ""
|
||||
if len(album.GetArtists()) > 0 {
|
||||
artistName = album.GetArtists()[0].GetArtist().GetName()
|
||||
}
|
||||
|
||||
w.sendStatusWithAlbumInfo(pb.MonitorStep_MONITOR_STEP_FETCHING_METADATA,
|
||||
fmt.Sprintf("Got metadata: %s - %s", artistName, album.GetTitle()),
|
||||
&pb.StreamAlbumInfo{
|
||||
Artist: artistName,
|
||||
Title: album.GetTitle(),
|
||||
ReleaseDate: album.GetReleaseDate(),
|
||||
})
|
||||
|
||||
w.sendStatus(pb.MonitorStep_MONITOR_STEP_CHECKING_OWNED, "Checking if already owned...")
|
||||
|
||||
dbAlbum, _ := w.service.metadata.GetAlbumByExternalID(ctx, album.GetId())
|
||||
if dbAlbum != nil {
|
||||
w.service.metadata.SetAlbumMonitorState(ctx, dbAlbum.ID, database.Monitored)
|
||||
dbAlbum.MonitorState = database.Monitored
|
||||
|
||||
qualityStr := normalizeQuality(w.req.Quality, 0, 0)
|
||||
owned, err := w.service.downloads.HasAlbumInQuality(ctx, dbAlbum.ID, w.req.Quality.String(), qualityStr)
|
||||
if err == nil && owned {
|
||||
w.sendStatus(pb.MonitorStep_MONITOR_STEP_CHECKING_OWNED,
|
||||
fmt.Sprintf("Already owned in %s quality", qualityStr))
|
||||
|
||||
if w.mode == pb.InteractionMode_INTERACTION_MODE_MANUAL {
|
||||
decision, err := w.promptAndWait(ctx, &pb.PromptForDecision{
|
||||
PromptId: w.nextPromptID(),
|
||||
Type: pb.PromptType_PROMPT_TYPE_CONFIRM,
|
||||
Message: "Album already owned. Download anyway?",
|
||||
|
||||
Options: &pb.PromptForDecision_Confirm{
|
||||
Confirm: &pb.ConfirmPrompt{
|
||||
ConfirmLabel: "Download anyway",
|
||||
CancelLabel: "Skip",
|
||||
DefaultValue: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
w.sendError(pb.MonitorStep_MONITOR_STEP_CHECKING_OWNED, err, false)
|
||||
return err
|
||||
}
|
||||
if !decision.GetConfirm() {
|
||||
w.sendStatus(pb.MonitorStep_MONITOR_STEP_COMPLETE, "Skipped - already owned")
|
||||
return w.sendResult(w.service.buildMonitorAlbumResponse(ctx, album, dbAlbum, nil))
|
||||
}
|
||||
} else {
|
||||
w.sendStatus(pb.MonitorStep_MONITOR_STEP_COMPLETE, "Already owned")
|
||||
return w.sendResult(w.service.buildMonitorAlbumResponse(ctx, album, dbAlbum, nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.sendStatus(pb.MonitorStep_MONITOR_STEP_SEARCHING_INDEXER,
|
||||
fmt.Sprintf("Searching indexers for %s - %s...", artistName, album.GetTitle()))
|
||||
|
||||
searchResult, err := w.service.searchIndexer(album, w.req.IndexerOptions.GetTracker())
|
||||
if err != nil {
|
||||
w.sendError(pb.MonitorStep_MONITOR_STEP_SEARCHING_INDEXER, err, true)
|
||||
return err
|
||||
}
|
||||
|
||||
parsed := w.service.parseSearchResults(searchResult, album)
|
||||
|
||||
if len(parsed) > 0 {
|
||||
summaries := make([]*pb.TorrentSummary, len(parsed))
|
||||
for i, p := range parsed {
|
||||
summaries[i] = &pb.TorrentSummary{
|
||||
Id: fmt.Sprintf("torrent-%d", i),
|
||||
Title: p.item.Title,
|
||||
Tracker: p.item.Tracker,
|
||||
Seeders: int32(p.item.Seeders),
|
||||
Format: p.rel.Format.String(),
|
||||
Lossless: p.rel.Format.IsLossless(),
|
||||
}
|
||||
}
|
||||
w.sendStatusWithTorrents(pb.MonitorStep_MONITOR_STEP_PARSING_RESULTS,
|
||||
fmt.Sprintf("Parsed %d from %d torrents", len(parsed), len(searchResult.Items)),
|
||||
&pb.TorrentList{Torrents: summaries})
|
||||
} else {
|
||||
w.sendStatus(pb.MonitorStep_MONITOR_STEP_PARSING_RESULTS,
|
||||
fmt.Sprintf("Found %d torrents, none parseable", len(searchResult.Items)))
|
||||
}
|
||||
|
||||
if len(parsed) == 0 {
|
||||
w.sendStatus(pb.MonitorStep_MONITOR_STEP_COMPLETE, "No parseable results found")
|
||||
return w.sendResult(w.service.buildMonitorAlbumResponse(ctx, album, dbAlbum, nil))
|
||||
}
|
||||
|
||||
if w.mode == pb.InteractionMode_INTERACTION_MODE_MANUAL && len(parsed) > 1 {
|
||||
options := make([]*pb.SelectOption, len(parsed))
|
||||
defaultIDs := make([]string, len(parsed))
|
||||
for i, p := range parsed {
|
||||
id := fmt.Sprintf("torrent-%d", i)
|
||||
options[i] = &pb.SelectOption{
|
||||
Id: id,
|
||||
Label: p.item.Title,
|
||||
Description: fmt.Sprintf("%s - %d seeders", p.item.Tracker, p.item.Seeders),
|
||||
}
|
||||
defaultIDs[i] = id
|
||||
}
|
||||
|
||||
decision, err := w.promptAndWait(ctx, &pb.PromptForDecision{
|
||||
PromptId: w.nextPromptID(),
|
||||
Type: pb.PromptType_PROMPT_TYPE_SELECT_MANY,
|
||||
Message: "Select torrents to consider",
|
||||
|
||||
Options: &pb.PromptForDecision_SelectMany{
|
||||
SelectMany: &pb.SelectManyPrompt{
|
||||
Options: options,
|
||||
DefaultIds: defaultIDs,
|
||||
MinSelections: 1,
|
||||
MaxSelections: int32(len(parsed)),
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
w.sendError(pb.MonitorStep_MONITOR_STEP_PARSING_RESULTS, err, false)
|
||||
return err
|
||||
}
|
||||
|
||||
selectedIDs := make(map[string]bool)
|
||||
if ids := decision.GetSelectedIds(); ids != nil {
|
||||
for _, id := range ids.GetIds() {
|
||||
selectedIDs[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
var selected []parsedItem
|
||||
for i, p := range parsed {
|
||||
id := fmt.Sprintf("torrent-%d", i)
|
||||
if selectedIDs[id] {
|
||||
selected = append(selected, p)
|
||||
}
|
||||
}
|
||||
if len(selected) > 0 {
|
||||
parsed = selected
|
||||
}
|
||||
}
|
||||
|
||||
w.sendStatus(pb.MonitorStep_MONITOR_STEP_FILTERING_QUALITY,
|
||||
fmt.Sprintf("Filtering %d results by quality...", len(parsed)))
|
||||
|
||||
filtered := filterByQuality(parsed, w.req.Quality)
|
||||
if len(filtered) == 0 {
|
||||
log.Warn().Str("album", album.GetTitle()).Str("quality", w.req.Quality.String()).Msg("no releases match quality filter")
|
||||
w.sendStatus(pb.MonitorStep_MONITOR_STEP_COMPLETE, "No releases match quality filter")
|
||||
return w.sendResult(w.service.buildMonitorAlbumResponse(ctx, album, dbAlbum, nil))
|
||||
}
|
||||
|
||||
var best parsedItem
|
||||
if w.mode == pb.InteractionMode_INTERACTION_MODE_MANUAL && len(filtered) > 1 {
|
||||
options := make([]*pb.SelectOption, len(filtered))
|
||||
for i, p := range filtered {
|
||||
options[i] = &pb.SelectOption{
|
||||
Id: fmt.Sprintf("release-%d", i),
|
||||
Label: p.item.Title,
|
||||
Description: fmt.Sprintf("%s - %d seeders - %s", p.item.Tracker, p.item.Seeders, p.rel.Format.String()),
|
||||
}
|
||||
}
|
||||
|
||||
bestIdx := 0
|
||||
for i, p := range filtered {
|
||||
if p.item.Seeders > filtered[bestIdx].item.Seeders {
|
||||
bestIdx = i
|
||||
}
|
||||
}
|
||||
|
||||
decision, err := w.promptAndWait(ctx, &pb.PromptForDecision{
|
||||
PromptId: w.nextPromptID(),
|
||||
Type: pb.PromptType_PROMPT_TYPE_SELECT_ONE,
|
||||
Message: "Select release",
|
||||
|
||||
Options: &pb.PromptForDecision_SelectOne{
|
||||
SelectOne: &pb.SelectOnePrompt{
|
||||
Options: options,
|
||||
DefaultId: fmt.Sprintf("release-%d", bestIdx),
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
w.sendError(pb.MonitorStep_MONITOR_STEP_SELECTING_RELEASE, err, false)
|
||||
return err
|
||||
}
|
||||
|
||||
selectedIdx := 0
|
||||
if id := decision.GetSelectedId(); id != "" {
|
||||
for i := range filtered {
|
||||
if fmt.Sprintf("release-%d", i) == id {
|
||||
selectedIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
best = filtered[selectedIdx]
|
||||
} else {
|
||||
best = selectBestRelease(filtered)
|
||||
}
|
||||
|
||||
w.sendStatusWithRelease(pb.MonitorStep_MONITOR_STEP_SELECTING_RELEASE,
|
||||
fmt.Sprintf("Selected: %s (%d seeders)", best.item.Title, best.item.Seeders),
|
||||
&pb.ReleaseInfo{
|
||||
InfoHash: best.rel.InfoHash,
|
||||
Format: best.rel.Format.String(),
|
||||
BitDepth: int32(best.rel.BitDepth),
|
||||
SampleRate: int32(best.rel.SampleRate),
|
||||
Seeders: int32(best.item.Seeders),
|
||||
Tracker: best.item.Tracker,
|
||||
})
|
||||
|
||||
w.sendStatus(pb.MonitorStep_MONITOR_STEP_ADDING_TORRENT,
|
||||
fmt.Sprintf("Adding torrent: %s...", best.item.Title))
|
||||
|
||||
if w.mode == pb.InteractionMode_INTERACTION_MODE_MANUAL {
|
||||
decision, err := w.promptAndWait(ctx, &pb.PromptForDecision{
|
||||
PromptId: w.nextPromptID(),
|
||||
Type: pb.PromptType_PROMPT_TYPE_CONFIRM,
|
||||
Message: fmt.Sprintf("Add torrent '%s' to client?", best.item.Title),
|
||||
|
||||
Options: &pb.PromptForDecision_Confirm{
|
||||
Confirm: &pb.ConfirmPrompt{
|
||||
ConfirmLabel: "Add",
|
||||
CancelLabel: "Skip",
|
||||
DefaultValue: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
w.sendError(pb.MonitorStep_MONITOR_STEP_ADDING_TORRENT, err, false)
|
||||
return err
|
||||
}
|
||||
if !decision.GetConfirm() {
|
||||
w.sendStatus(pb.MonitorStep_MONITOR_STEP_COMPLETE, "Skipped by user")
|
||||
return w.sendResult(w.service.buildMonitorAlbumResponse(ctx, album, dbAlbum, nil))
|
||||
}
|
||||
}
|
||||
|
||||
if err := w.service.addToTorrentClient(best); err != nil {
|
||||
w.sendError(pb.MonitorStep_MONITOR_STEP_ADDING_TORRENT, err, true)
|
||||
return err
|
||||
}
|
||||
|
||||
w.sendStatus(pb.MonitorStep_MONITOR_STEP_SAVING, "Saving to database...")
|
||||
|
||||
dbAlbum, _ = w.service.metadata.GetAlbumByExternalID(ctx, album.GetId())
|
||||
if dbAlbum != nil {
|
||||
w.service.saveTorrentAndDownload(ctx, dbAlbum.ID, best)
|
||||
} else {
|
||||
log.Warn().Str("album_id", w.req.AlbumId).Msg("album not in DB, skipping torrent/download persistence")
|
||||
}
|
||||
|
||||
w.sendStatus(pb.MonitorStep_MONITOR_STEP_COMPLETE, "Done!")
|
||||
|
||||
return w.sendResult(w.service.buildMonitorAlbumResponse(ctx, album, dbAlbum, &best))
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"github.com/riverqueue/river"
|
||||
"github.com/rs/zerolog/log"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
pb "homelab.lan/music-agregator/gen/music_agregator/v1"
|
||||
"homelab.lan/music-agregator/internal/config"
|
||||
@@ -46,6 +48,29 @@ func (s *MusicAgregatorServer) MonitorAlbum(ctx context.Context, req *pb.Monitor
|
||||
return s.service.MonitorAlbum(ctx, req)
|
||||
}
|
||||
|
||||
func (s *MusicAgregatorServer) MonitorAlbumStream(stream pb.MusicAgregatorService_MonitorAlbumStreamServer) error {
|
||||
msg, err := stream.Recv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
startReq := msg.GetStart()
|
||||
if startReq == nil {
|
||||
return status.Error(codes.InvalidArgument, "first message must be StartMonitorRequest")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(stream.Context())
|
||||
defer cancel()
|
||||
|
||||
workflow := newMonitorWorkflow(stream, startReq, s.service, cancel)
|
||||
|
||||
if startReq.Mode == pb.InteractionMode_INTERACTION_MODE_MANUAL {
|
||||
go workflow.receiveDecisions(ctx)
|
||||
}
|
||||
|
||||
return workflow.run(ctx)
|
||||
}
|
||||
|
||||
func (s *MusicAgregatorServer) AnalyzeAlbumRelease(ctx context.Context, req *pb.AnalyzeAlbumReleaseRequest) (*pb.AnalyzeAlbumReleaseResponse, error) {
|
||||
return s.service.AnalyzeAlbumRelease(ctx, req)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ option go_package = "homelab.lan/music-agregator/gen/music_agregator/v1/";
|
||||
|
||||
service MusicAgregatorService {
|
||||
rpc MonitorAlbum(MonitorAlbumRequest) returns (MonitorAlbumResponse) {}
|
||||
rpc MonitorAlbumStream(stream MonitorAlbumStreamRequest) returns (stream MonitorAlbumStreamResponse) {}
|
||||
rpc GetArtists(GetArtistsRequest) returns (GetArtistsResponse) {}
|
||||
rpc GetAlbum(GetAlbumRequest) returns (GetAlbumResponse) {}
|
||||
rpc AnalyzeAlbumRelease(AnalyzeAlbumReleaseRequest) returns (AnalyzeAlbumReleaseResponse) {}
|
||||
@@ -161,3 +162,149 @@ message MonitoredRelease {
|
||||
int32 seeders = 17;
|
||||
string tracker = 18;
|
||||
}
|
||||
|
||||
enum InteractionMode {
|
||||
INTERACTION_MODE_AUTOMATIC = 0;
|
||||
INTERACTION_MODE_MANUAL = 1;
|
||||
}
|
||||
|
||||
enum MonitorStep {
|
||||
MONITOR_STEP_UNSPECIFIED = 0;
|
||||
MONITOR_STEP_FETCHING_METADATA = 1;
|
||||
MONITOR_STEP_CHECKING_OWNED = 2;
|
||||
MONITOR_STEP_SEARCHING_INDEXER = 3;
|
||||
MONITOR_STEP_PARSING_RESULTS = 4;
|
||||
MONITOR_STEP_FILTERING_QUALITY = 5;
|
||||
MONITOR_STEP_SELECTING_RELEASE = 6;
|
||||
MONITOR_STEP_ADDING_TORRENT = 7;
|
||||
MONITOR_STEP_SAVING = 8;
|
||||
MONITOR_STEP_COMPLETE = 9;
|
||||
}
|
||||
|
||||
enum PromptType {
|
||||
PROMPT_TYPE_UNSPECIFIED = 0;
|
||||
PROMPT_TYPE_CONFIRM = 1;
|
||||
PROMPT_TYPE_SELECT_ONE = 2;
|
||||
PROMPT_TYPE_SELECT_MANY = 3;
|
||||
}
|
||||
|
||||
message MonitorAlbumStreamRequest {
|
||||
oneof message {
|
||||
StartMonitorRequest start = 1;
|
||||
UserDecision decision = 2;
|
||||
CancelRequest cancel = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message StartMonitorRequest {
|
||||
string album_id = 1;
|
||||
IndexerOptions indexer_options = 2;
|
||||
QualityType quality = 3;
|
||||
InteractionMode mode = 4;
|
||||
}
|
||||
|
||||
message UserDecision {
|
||||
string prompt_id = 1;
|
||||
oneof decision {
|
||||
bool confirm = 2;
|
||||
string selected_id = 3;
|
||||
SelectedIds selected_ids = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message SelectedIds {
|
||||
repeated string ids = 1;
|
||||
}
|
||||
|
||||
message CancelRequest {}
|
||||
|
||||
message MonitorAlbumStreamResponse {
|
||||
oneof message {
|
||||
StatusUpdate status = 1;
|
||||
PromptForDecision prompt = 2;
|
||||
MonitorAlbumResponse result = 3;
|
||||
ErrorUpdate error = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message StatusUpdate {
|
||||
MonitorStep step = 1;
|
||||
string message = 2;
|
||||
oneof data {
|
||||
StreamAlbumInfo album_info = 10;
|
||||
TorrentList torrents = 11;
|
||||
ReleaseInfo release_info = 12;
|
||||
}
|
||||
}
|
||||
|
||||
message PromptForDecision {
|
||||
string prompt_id = 1;
|
||||
PromptType type = 2;
|
||||
string message = 3;
|
||||
int32 timeout_seconds = 4;
|
||||
oneof options {
|
||||
ConfirmPrompt confirm = 10;
|
||||
SelectOnePrompt select_one = 11;
|
||||
SelectManyPrompt select_many = 12;
|
||||
}
|
||||
}
|
||||
|
||||
message ErrorUpdate {
|
||||
MonitorStep failed_step = 1;
|
||||
string message = 2;
|
||||
bool recoverable = 3;
|
||||
}
|
||||
|
||||
message StreamAlbumInfo {
|
||||
string artist = 1;
|
||||
string title = 2;
|
||||
string release_date = 3;
|
||||
bool already_owned = 4;
|
||||
string owned_quality = 5;
|
||||
}
|
||||
|
||||
message TorrentList {
|
||||
repeated TorrentSummary torrents = 1;
|
||||
}
|
||||
|
||||
message TorrentSummary {
|
||||
string id = 1;
|
||||
string title = 2;
|
||||
string tracker = 3;
|
||||
int32 seeders = 4;
|
||||
string format = 5;
|
||||
bool lossless = 6;
|
||||
}
|
||||
|
||||
message ReleaseInfo {
|
||||
string info_hash = 1;
|
||||
string format = 2;
|
||||
int32 bit_depth = 3;
|
||||
int32 sample_rate = 4;
|
||||
int32 seeders = 5;
|
||||
string tracker = 6;
|
||||
}
|
||||
|
||||
message ConfirmPrompt {
|
||||
string confirm_label = 1;
|
||||
string cancel_label = 2;
|
||||
bool default_value = 3;
|
||||
}
|
||||
|
||||
message SelectOnePrompt {
|
||||
repeated SelectOption options = 1;
|
||||
string default_id = 2;
|
||||
}
|
||||
|
||||
message SelectManyPrompt {
|
||||
repeated SelectOption options = 1;
|
||||
repeated string default_ids = 2;
|
||||
int32 min_selections = 3;
|
||||
int32 max_selections = 4;
|
||||
}
|
||||
|
||||
message SelectOption {
|
||||
string id = 1;
|
||||
string label = 2;
|
||||
string description = 3;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user