Files
SEEN/backend/internal/services/download/service.go
T
2026-04-10 12:06:24 +02:00

340 lines
8.7 KiB
Go

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)
}