mirror of
https://github.com/Dvorinka/SEEN.git
synced 2026-06-04 20:43:03 +00:00
340 lines
8.7 KiB
Go
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)
|
|
}
|