* feat(downloads): relay node-local artifacts through proxies * fix(downloads): harden remote artifact relay * fix(downloads): close distributed artifact review gaps * fix(downloads): bound relays and refresh origins * fix(downloads): recover stalled remote artifacts * fix(transcode): report session cleanup failures * fix(downloads): harden artifact cleanup recovery * fix(downloads): close remote artifact lifecycle races * fix(downloads): honor configured artifact storage * fix(downloads): recover proxy-observed artifact misses * test(downloads): isolate remote cleanup assertions * fix(downloads): bound remote artifact recovery * fix(downloads): allow concurrent artifact relays * fix(downloads): harden distributed artifact recovery
149 lines
6.3 KiB
Go
149 lines
6.3 KiB
Go
// Package downloads owns the downloads domain: a single device-aware,
|
|
// quality-aware registry shared by the web app and mobile clients for offline
|
|
// playback. It replaces the former internal/download package, absorbing its
|
|
// bandwidth/quota/serving logic and reshaping the table and /downloads contract. See
|
|
// docs/superpowers/specs/2026-06-18-offline-sync-mobile-design.md.
|
|
package downloads
|
|
|
|
import (
|
|
"errors"
|
|
"time"
|
|
)
|
|
|
|
// Format constants are internal delivery formats recorded on a download row.
|
|
// They are not user-facing quality presets: clients choose a Quality*, then the
|
|
// service records the concrete delivery format it can fulfill.
|
|
const (
|
|
FormatOriginal = "original"
|
|
FormatRemux = "remux"
|
|
FormatTranscode = "transcode"
|
|
)
|
|
|
|
// Quality constants are the public download choices clients may request.
|
|
const (
|
|
QualityOriginal = "original"
|
|
Quality20Mbps = "20mbps"
|
|
Quality10Mbps = "10mbps"
|
|
Quality5Mbps = "5mbps"
|
|
Quality2Mbps = "2mbps"
|
|
Quality1Mbps = "1mbps"
|
|
)
|
|
|
|
// QualityPresets is the ordered UI/API ladder for download quality.
|
|
var QualityPresets = []string{
|
|
QualityOriginal,
|
|
Quality20Mbps,
|
|
Quality10Mbps,
|
|
Quality5Mbps,
|
|
Quality2Mbps,
|
|
Quality1Mbps,
|
|
}
|
|
|
|
// Download status constants.
|
|
const (
|
|
// Ephemeral / web lifecycle (unchanged from downloads v1):
|
|
// queued -> downloading -> completed | failed | canceled.
|
|
StatusQueued = "queued"
|
|
StatusDownloading = "downloading"
|
|
StatusCompleted = "completed"
|
|
StatusFailed = "failed"
|
|
StatusCancelled = "cancelled" //nolint:misspell // persisted DB enum value (migration 042)
|
|
|
|
// Managed device-entry lifecycle (Phase 1+):
|
|
// [preparing ->] ready -> completed, plus revoked (reserved for the
|
|
// admin revoke flow; see the download limits & restrictions design).
|
|
StatusPreparing = "preparing"
|
|
StatusReady = "ready"
|
|
StatusRevoked = "revoked"
|
|
)
|
|
|
|
// Download kind constants.
|
|
const (
|
|
KindQueued = "queued"
|
|
)
|
|
|
|
// Sentinel errors.
|
|
var (
|
|
ErrNotFound = errors.New("download not found")
|
|
ErrDownloadNotAllowed = errors.New("user is not allowed to download")
|
|
ErrFeatureDisabled = errors.New("downloads are disabled")
|
|
ErrConcurrentLimitReached = errors.New("concurrent download limit reached")
|
|
ErrPeriodLimitReached = errors.New("download period limit reached")
|
|
ErrDownloadNotActive = errors.New("download is not in an active state")
|
|
ErrStatusConflict = errors.New("download status transition conflict")
|
|
ErrTranscodeDisabled = errors.New("download transcode is disabled")
|
|
ErrInvalidQuality = errors.New("invalid download quality")
|
|
ErrProfileRequired = errors.New("managed download requires a profile")
|
|
ErrInvalidStatus = errors.New("invalid download status transition")
|
|
ErrManifestUnavailable = errors.New("offline manifest is not available")
|
|
ErrInvalidSubtitleRef = errors.New("invalid subtitle reference")
|
|
ErrAssetNotFound = errors.New("download asset not found")
|
|
ErrFormatUnavailable = errors.New("requested download format is not available")
|
|
// ErrResponseCommitted reports a transfer failure after response headers
|
|
// were written. Handlers must not append an API error body, while service
|
|
// lifecycle code must still treat the transfer as incomplete.
|
|
ErrResponseCommitted = errors.New("download response already committed")
|
|
// ErrArtifactOriginRemoved means a durable remote locator points at a node
|
|
// no longer present in the enabled transcode pool.
|
|
ErrArtifactOriginRemoved = errors.New("download artifact origin node was removed")
|
|
// ErrQualityUnavailable means the requested quality is valid and permitted in
|
|
// principle but cannot be fulfilled by the current server wiring.
|
|
ErrQualityUnavailable = errors.New("requested download quality is not available")
|
|
// ErrBulkQualityUnavailable keeps season/series batches original-only until
|
|
// batch artifact UX and storage reporting are explicit.
|
|
ErrBulkQualityUnavailable = errors.New("bulk quality downloads are not available")
|
|
// ErrNoDownloadableEpisodes means a series/season download matched a valid
|
|
// series but none of its episodes have a downloadable file.
|
|
ErrNoDownloadableEpisodes = errors.New("no downloadable episodes found")
|
|
|
|
// Series-monitoring (download subscription) errors.
|
|
ErrSubscriptionNotFound = errors.New("download subscription not found")
|
|
ErrSubscriptionsUnavailable = errors.New("download subscriptions are not available")
|
|
ErrInvalidSubscriptionMode = errors.New("invalid subscription mode")
|
|
ErrSeasonsRequired = errors.New("season_numbers is required for specific_seasons mode")
|
|
ErrInvalidSeasonNumbers = errors.New("season_numbers contains an out-of-range season number")
|
|
ErrNotSeries = errors.New("content_id is not a series")
|
|
)
|
|
|
|
// Download represents a row in the downloads table. It carries both the
|
|
// ephemeral/web lifecycle (DeviceID == "") and the managed device-entry
|
|
// lifecycle (DeviceID set); ProfileID/DeviceID/ArtifactID map to nullable
|
|
// columns and are empty strings when unset.
|
|
type Download struct {
|
|
ID string
|
|
UserID int
|
|
ProfileID string // "" for ephemeral/web rows
|
|
DeviceID string // "" = ephemeral; set = managed device entry
|
|
MediaFileID int
|
|
ContentID string
|
|
EpisodeID string
|
|
BatchID string
|
|
Kind string // direct or queued
|
|
Status string // see Status* constants
|
|
Format string // internal delivery format; see Format* constants
|
|
Quality string // user-requested quality; see Quality* constants
|
|
EffectiveQuality string // actual quality after original compatibility fallback
|
|
TargetBitrateKbps int // 0 for original/remux; bitrate cap for transcode
|
|
Revision int // increments when a managed row is replaced in place
|
|
ArtifactID string // "" until a remux/transcode artifact is linked (Phase 3)
|
|
FileSize int64
|
|
BytesSent int64
|
|
ErrorMessage string
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
CompletedAt *time.Time
|
|
}
|
|
|
|
// SkippedDownload explains why a bulk series/season request did not create a
|
|
// row for an episode.
|
|
type SkippedDownload struct {
|
|
EpisodeID string `json:"episode_id"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// IsManaged reports whether this is a managed device-library entry (DeviceID set)
|
|
// rather than an ephemeral/account-level row.
|
|
func (d *Download) IsManaged() bool {
|
|
return d.DeviceID != ""
|
|
}
|