package download import ( "context" "errors" "slices" "strings" "time" "github.com/google/uuid" ) type Status string const ( StatusQueued Status = "queued" StatusPreparing Status = "preparing" StatusDownloading Status = "downloading" StatusStalled Status = "stalled" StatusRetrying Status = "retrying" StatusCompleted Status = "completed" StatusFailed Status = "failed" StatusCancelled Status = "cancelled" ) var ( ErrInvalidInput = errors.New("invalid input") ErrNotFound = errors.New("download job not found") ) type Job struct { ID string `json:"id"` UserID string `json:"userId,omitempty"` SourceType string `json:"sourceType"` Source string `json:"source,omitempty"` Title string `json:"title"` Status string `json:"status"` QueuePosition int `json:"queuePosition,omitempty"` ProgressPercent int `json:"progressPercent"` BytesTotal int64 `json:"bytesTotal"` BytesDownloaded int64 `json:"bytesDownloaded"` DownloadSpeedMbps float64 `json:"downloadSpeedMbps"` EtaSeconds int `json:"etaSeconds,omitempty"` ErrorMessage string `json:"errorMessage,omitempty"` RetryCount int `json:"retryCount"` CreatedAt time.Time `json:"createdAt,omitempty"` UpdatedAt time.Time `json:"updatedAt,omitempty"` StartedAt time.Time `json:"startedAt,omitempty"` CompletedAt time.Time `json:"completedAt,omitempty"` CancelledAt time.Time `json:"cancelledAt,omitempty"` } type Event struct { ID int64 `json:"id"` JobID string `json:"jobId"` Status string `json:"status"` Message string `json:"message"` ProgressPercent int `json:"progressPercent"` Payload string `json:"payload"` CreatedAt time.Time `json:"createdAt"` } type CreateInput struct { SourceType string Source string Title string } type ListParams struct { Status string Limit int Offset int } type EventParams struct { Limit int After string } type UpdateInput struct { Status string ProgressPercent int BytesTotal int64 BytesDownloaded int64 DownloadSpeedMbps float64 EtaSeconds int ErrorMessage string RetryCount int } type Repository interface { CreateJob(ctx context.Context, userID uuid.UUID, input CreateInput) (Job, error) ListJobs(ctx context.Context, userID uuid.UUID, params ListParams) ([]Job, error) GetJobByID(ctx context.Context, userID uuid.UUID, jobID string) (Job, error) CancelJob(ctx context.Context, userID uuid.UUID, jobID string) (Job, error) ListEvents(ctx context.Context, userID uuid.UUID, jobID string, params EventParams) ([]Event, error) AppendEvent(ctx context.Context, jobID string, event Event) error UpdateJob(ctx context.Context, userID uuid.UUID, jobID string, input UpdateInput) (Job, error) } type Service struct { repo Repository } func NewService(repo Repository) *Service { return &Service{repo: repo} } func (s *Service) Create(ctx context.Context, userID uuid.UUID, input CreateInput) (Job, error) { if userID == uuid.Nil { return Job{}, ErrInvalidInput } sourceType := strings.TrimSpace(strings.ToLower(input.SourceType)) source := strings.TrimSpace(input.Source) title := strings.TrimSpace(input.Title) if sourceType == "" || source == "" { return Job{}, ErrInvalidInput } if !isAllowedSourceType(sourceType) { return Job{}, ErrInvalidInput } return s.repo.CreateJob(ctx, userID, CreateInput{ SourceType: sourceType, Source: source, Title: title, }) } func (s *Service) List(ctx context.Context, userID uuid.UUID, params ListParams) ([]Job, error) { if userID == uuid.Nil { return nil, ErrInvalidInput } limit := params.Limit if limit < 1 || limit > 100 { limit = 20 } offset := params.Offset if offset < 0 { offset = 0 } status := strings.TrimSpace(strings.ToLower(params.Status)) if status != "" && !isAllowedStatus(status) { return nil, ErrInvalidInput } return s.repo.ListJobs(ctx, userID, ListParams{ Status: status, Limit: limit, Offset: offset, }) } func (s *Service) Cancel(ctx context.Context, userID uuid.UUID, jobID string) (Job, error) { if userID == uuid.Nil || strings.TrimSpace(jobID) == "" { return Job{}, ErrInvalidInput } job, err := s.repo.GetJobByID(ctx, userID, jobID) if err != nil { if errors.Is(err, ErrNotFound) { return Job{}, ErrNotFound } return Job{}, err } if isTerminalStatus(job.Status) { return job, nil } updated, err := s.repo.CancelJob(ctx, userID, jobID) if err != nil { if errors.Is(err, ErrNotFound) { return Job{}, ErrNotFound } return Job{}, err } _ = s.repo.AppendEvent(ctx, updated.ID, Event{ Status: StatusCancelled.String(), Message: "job cancelled by user", ProgressPercent: updated.ProgressPercent, Payload: "{}", }) return updated, nil } func (s *Service) Events(ctx context.Context, userID uuid.UUID, jobID string, params EventParams) ([]Event, error) { if userID == uuid.Nil || strings.TrimSpace(jobID) == "" { return nil, ErrInvalidInput } limit := params.Limit if limit < 1 || limit > 200 { limit = 100 } _, err := s.repo.GetJobByID(ctx, userID, jobID) if err != nil { if errors.Is(err, ErrNotFound) { return nil, ErrNotFound } return nil, err } return s.repo.ListEvents(ctx, userID, jobID, EventParams{ Limit: limit, After: strings.TrimSpace(params.After), }) } func (s *Service) Transition(ctx context.Context, userID uuid.UUID, jobID string, input UpdateInput) (Job, error) { if userID == uuid.Nil || strings.TrimSpace(jobID) == "" { return Job{}, ErrInvalidInput } nextStatus := strings.TrimSpace(strings.ToLower(input.Status)) if !isAllowedStatus(nextStatus) { return Job{}, ErrInvalidInput } current, err := s.repo.GetJobByID(ctx, userID, jobID) if err != nil { if errors.Is(err, ErrNotFound) { return Job{}, ErrNotFound } return Job{}, err } if !canTransition(current.Status, nextStatus) { return Job{}, ErrInvalidInput } if input.ProgressPercent < current.ProgressPercent && !isTerminalStatus(nextStatus) { return Job{}, ErrInvalidInput } if nextStatus == StatusCompleted.String() { input.ProgressPercent = 100 } updated, err := s.repo.UpdateJob(ctx, userID, jobID, input) if err != nil { if errors.Is(err, ErrNotFound) { return Job{}, ErrNotFound } return Job{}, err } _ = s.repo.AppendEvent(ctx, updated.ID, Event{ Status: updated.Status, Message: eventMessageForStatus(updated.Status), ProgressPercent: updated.ProgressPercent, Payload: "{}", }) return updated, nil } func isAllowedSourceType(value string) bool { return slices.Contains([]string{"magnet", "torrent", "direct", "http"}, value) } func isAllowedStatus(value string) bool { return slices.Contains([]string{ StatusQueued.String(), StatusPreparing.String(), StatusDownloading.String(), StatusStalled.String(), StatusRetrying.String(), StatusCompleted.String(), StatusFailed.String(), StatusCancelled.String(), }, value) } func isTerminalStatus(value string) bool { return slices.Contains([]string{ StatusCompleted.String(), StatusFailed.String(), StatusCancelled.String(), }, strings.TrimSpace(strings.ToLower(value))) } func canTransition(from string, to string) bool { current := strings.TrimSpace(strings.ToLower(from)) next := strings.TrimSpace(strings.ToLower(to)) if current == next { return true } switch current { case StatusQueued.String(): return next == StatusPreparing.String() || next == StatusCancelled.String() case StatusPreparing.String(): return next == StatusDownloading.String() || next == StatusCancelled.String() case StatusDownloading.String(): return next == StatusStalled.String() || next == StatusCompleted.String() || next == StatusFailed.String() || next == StatusCancelled.String() case StatusStalled.String(): return next == StatusRetrying.String() || next == StatusCancelled.String() case StatusRetrying.String(): return next == StatusPreparing.String() || next == StatusCancelled.String() default: return false } } func eventMessageForStatus(status string) string { switch strings.TrimSpace(strings.ToLower(status)) { case StatusPreparing.String(): return "job preparing" case StatusDownloading.String(): return "download started" case StatusStalled.String(): return "download stalled" case StatusRetrying.String(): return "retrying stalled download" case StatusCompleted.String(): return "download completed" case StatusFailed.String(): return "download failed" case StatusCancelled.String(): return "download cancelled" default: return "download updated" } } func (s Status) String() string { return string(s) }