Files
silo-server/internal/api/handlers/downloads.go
b7292a9473 fix(streaming): stop killing healthy streams at the server WriteTimeout (#361)
The main API server's WriteTimeout (120s) is an absolute deadline from
request start, so every streaming response still being written at T+120s
was cut mid-body with a clean close. Clients saw multi-GB direct streams
truncate every two minutes; the Apple client's cursor-resume reconnect
absorbed most kills silently, but one landing during backpressure or a
demuxer resync exhausted its retry budget and forced a full player
teardown (visible stop + historical audio desync seeding).

Fix: internal/httpstream.RollingDeadlineWriter pushes the connection's
write deadline forward with progress via http.ResponseController — a
response that keeps moving lives indefinitely, a stalled one is still
reaped within the window (180s default, SILO_STREAM_WRITE_STALL_TIMEOUT
to override). ReadFrom delegates in bounded slices so http.ServeContent
keeps its sendfile fast path. Wired into direct play, remux, downloads,
the transcode-node proxy, and ebook serving; the server-level 120s guard
stays for every other route.

The metrics and request-logger response writers now implement Unwrap —
without it http.ResponseController cannot traverse to the connection and
SetWriteDeadline fails, silently disabling the fix (exactly what the
first dev deploy showed). A middleware-chain integration test locks the
whole path down against future wrappers missing Unwrap.

Validated on dev: 200s/512MB direct and 300s/768MB via CDN sustained
range-GETs (previously dying at 120s), zero duration_ms=120000 stream
entries since deploy.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 23:07:16 -04:00

613 lines
26 KiB
Go

package handlers
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/downloads"
"github.com/Silo-Server/silo-server/internal/httpstream"
"github.com/Silo-Server/silo-server/internal/playback"
)
// DownloadService is the interface that the download handler depends on. A
// non-empty deviceID (from the X-Silo-Device-Id header) selects the managed
// device-library lifecycle; empty is the ephemeral/account-level path.
type DownloadService interface {
Capability(ctx context.Context, userID int) (downloads.Capability, error)
Create(ctx context.Context, userID int, req downloads.CreateRequest, filter catalog.AccessFilter) (*downloads.Download, error)
CreateSeries(ctx context.Context, userID int, req downloads.CreateRequest, filter catalog.AccessFilter) ([]*downloads.Download, string, []downloads.SkippedDownload, error)
CreateSeason(ctx context.Context, userID int, req downloads.CreateRequest, seasonNumber int, filter catalog.AccessFilter) ([]*downloads.Download, string, []downloads.SkippedDownload, error)
CreateSubscription(ctx context.Context, userID int, req downloads.SubscriptionRequest, filter catalog.AccessFilter) (*downloads.SubscriptionResult, error)
ListSubscriptions(ctx context.Context, userID int, profileID, deviceID string) ([]*downloads.Subscription, error)
GetSubscription(ctx context.Context, userID int, profileID, deviceID, id string) (*downloads.Subscription, error)
UpdateSubscription(ctx context.Context, userID int, profileID, deviceID, id string, patch downloads.SubscriptionPatch, filter catalog.AccessFilter) (*downloads.SubscriptionResult, error)
DeleteSubscription(ctx context.Context, userID int, profileID, deviceID, id string) error
SyncSubscriptions(ctx context.Context, userID int, profileID, deviceID string, filter catalog.AccessFilter) (int, error)
ServeDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, userID, fileID int, format string, filter catalog.AccessFilter) error
ServeFile(ctx context.Context, w http.ResponseWriter, r *http.Request, userID int, profileID, deviceID, downloadID string, filter catalog.AccessFilter) error
List(ctx context.Context, userID int, profileID, deviceID string) ([]*downloads.Download, error)
Delete(ctx context.Context, userID int, profileID, deviceID, downloadID string) error
PatchStatus(ctx context.Context, userID int, profileID, deviceID, downloadID, status string) error
BuildManifest(ctx context.Context, userID int, profileID, deviceID, downloadID string, filter catalog.AccessFilter) (*downloads.OfflineManifest, error)
BuildBatchManifests(ctx context.Context, userID int, profileID, deviceID, batchID string, filter catalog.AccessFilter) ([]*downloads.OfflineManifest, []downloads.SkippedManifest, error)
ServeArtwork(ctx context.Context, w http.ResponseWriter, r *http.Request, userID int, profileID, deviceID, downloadID, kind string, filter catalog.AccessFilter) error
ServeSubtitle(ctx context.Context, w http.ResponseWriter, r *http.Request, userID int, profileID, deviceID, downloadID, ref string, filter catalog.AccessFilter) error
}
// DownloadHandler handles download endpoints.
type DownloadHandler struct {
svc DownloadService
}
// NewDownloadHandler creates a new DownloadHandler.
func NewDownloadHandler(svc DownloadService) *DownloadHandler {
return &DownloadHandler{svc: svc}
}
// downloadRequest represents the JSON body for POST /downloads.
type downloadRequest struct {
ContentID string `json:"content_id"`
EpisodeID string `json:"episode_id,omitempty"`
FileID int `json:"file_id,omitempty"`
Quality string `json:"quality,omitempty"` // original (default) | 20mbps | 10mbps | 5mbps | 2mbps | 1mbps
Series bool `json:"series,omitempty"` // if true, downloads all episodes
Season *int `json:"season_number,omitempty"` // with series=true, downloads only this season (0 = Specials)
Caps *downloadCaps `json:"caps,omitempty"` // device decode capability (original fallback / transcode target)
}
// downloadCaps mirrors playback.ClientCapabilities for the request body.
type downloadCaps struct {
CodecsVideo []string `json:"codecs_video,omitempty"`
CodecsAudio []string `json:"codecs_audio,omitempty"`
AudioPassthroughCodecs []string `json:"audio_passthrough_codecs,omitempty"`
Containers []string `json:"containers,omitempty"`
MaxResolution string `json:"max_resolution,omitempty"`
HDR bool `json:"hdr,omitempty"`
}
// patchDownloadRequest is the JSON body for PATCH /downloads/{id}.
type patchDownloadRequest struct {
Status string `json:"status"`
}
// downloadResponse represents a download entry in API responses.
type downloadResponse struct {
ID string `json:"id"`
ContentID string `json:"content_id"`
EpisodeID string `json:"episode_id,omitempty"`
BatchID string `json:"batch_id,omitempty"`
DeviceID string `json:"device_id,omitempty"`
MediaFileID int `json:"media_file_id"`
FileSize int64 `json:"file_size"`
BytesSent int64 `json:"bytes_sent"`
Kind string `json:"kind"`
Status string `json:"status"`
Quality string `json:"quality"`
EffectiveQuality string `json:"effective_quality"`
DeliveryFormat string `json:"delivery_format"`
TargetBitrateKbps int `json:"target_bitrate_kbps"`
Revision int `json:"revision"`
CreatedAt string `json:"created_at"`
CompletedAt *string `json:"completed_at,omitempty"`
}
// downloadsListResponse wraps the downloads list for JSON serialization.
type downloadsListResponse struct {
Downloads []downloadResponse `json:"downloads"`
Skipped []downloads.SkippedDownload `json:"skipped,omitempty"`
}
type batchManifestsResponse struct {
Manifests []*downloads.OfflineManifest `json:"manifests"`
// Skipped lists batch entries whose manifest could not be built (revoked,
// deleted from the catalog, access-filtered); the rest of the batch is
// still delivered.
Skipped []downloads.SkippedManifest `json:"skipped,omitempty"`
}
// downloadCapabilityResponse is the GET /downloads/capability payload clients
// use for feature detection instead of introspecting admin settings.
type downloadCapabilityResponse struct {
Enabled bool `json:"enabled"`
DownloadAllowed bool `json:"download_allowed"`
QualityPresets []string `json:"quality_presets"`
TranscodeEnabled bool `json:"transcode_enabled"`
TranscodeUserAllowed bool `json:"transcode_user_allowed"`
SeasonDownload bool `json:"season_download"`
SeriesMonitoring bool `json:"series_monitoring"`
MonitoringModes []string `json:"monitoring_modes,omitempty"`
}
func toDownloadResponse(d *downloads.Download) downloadResponse {
resp := downloadResponse{
ID: d.ID,
ContentID: d.ContentID,
EpisodeID: d.EpisodeID,
BatchID: d.BatchID,
DeviceID: d.DeviceID,
MediaFileID: d.MediaFileID,
FileSize: d.FileSize,
BytesSent: d.BytesSent,
Kind: d.Kind,
Status: d.Status,
Quality: d.Quality,
EffectiveQuality: d.EffectiveQuality,
DeliveryFormat: d.Format,
TargetBitrateKbps: d.TargetBitrateKbps,
Revision: d.Revision,
CreatedAt: d.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
if d.CompletedAt != nil {
s := d.CompletedAt.Format("2006-01-02T15:04:05Z07:00")
resp.CompletedAt = &s
}
return resp
}
// managedIdentity returns the (profileID, deviceID) the request is acting as.
// deviceID comes ONLY from the X-Silo-Device-Id header (never the body/query);
// profileID is resolved by the viewer-access middleware from X-Profile-Id.
func managedIdentity(r *http.Request) (profileID, deviceID, deviceName, devicePlatform string) {
device := deviceMetadataFromRequest(r)
return apimw.GetProfileID(r.Context()), device.DeviceID, device.DeviceName, device.DevicePlatform
}
// HandleCapability handles GET /downloads/capability.
func (h *DownloadHandler) HandleCapability(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
if h.svc == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Downloads not configured")
return
}
capInfo, err := h.svc.Capability(r.Context(), userID)
if err != nil {
slog.ErrorContext(r.Context(), "failed to load download capability", "component", "api", "user_id", userID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load download capability")
return
}
writeJSON(w, http.StatusOK, downloadCapabilityResponse{
Enabled: capInfo.Enabled,
DownloadAllowed: capInfo.DownloadAllowed,
QualityPresets: capInfo.QualityPresets,
TranscodeEnabled: capInfo.TranscodeEnabled,
TranscodeUserAllowed: capInfo.TranscodeUserAllowed,
SeasonDownload: capInfo.SeasonDownload,
SeriesMonitoring: capInfo.SeriesMonitoring,
MonitoringModes: capInfo.MonitoringModes,
})
}
// HandleCreateDownload handles POST /downloads. The X-Silo-Device-Id header
// (if present) makes this a managed device entry; otherwise it is ephemeral.
func (h *DownloadHandler) HandleCreateDownload(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
if h.svc == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Downloads not configured")
return
}
var req downloadRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if req.ContentID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "content_id is required")
return
}
profileID, deviceID, deviceName, devicePlatform := managedIdentity(r)
filter := requestAccessFilter(r)
createReq := downloads.CreateRequest{
ContentID: req.ContentID,
EpisodeID: req.EpisodeID,
FileID: req.FileID,
Quality: req.Quality,
ProfileID: profileID,
DeviceID: deviceID,
DeviceName: deviceName,
DevicePlatform: devicePlatform,
}
if req.Caps != nil {
createReq.Caps = playback.ClientCapabilities{
CodecsVideo: req.Caps.CodecsVideo,
CodecsAudio: req.Caps.CodecsAudio,
AudioPassthroughCodecs: req.Caps.AudioPassthroughCodecs,
Containers: req.Caps.Containers,
MaxResolution: req.Caps.MaxResolution,
HDR: req.Caps.HDR,
}
}
if req.Series {
var (
list []*downloads.Download
batchID string
skipped []downloads.SkippedDownload
err error
)
// Dispatch on presence, not value: season 0 is the Specials season.
if req.Season != nil {
if *req.Season < 0 {
writeError(w, http.StatusBadRequest, "bad_request", "season_number must be >= 0")
return
}
list, batchID, skipped, err = h.svc.CreateSeason(r.Context(), userID, createReq, *req.Season, filter)
} else {
list, batchID, skipped, err = h.svc.CreateSeries(r.Context(), userID, createReq, filter)
}
if err != nil {
h.writeDownloadError(w, err)
return
}
responses := make([]downloadResponse, 0, len(list))
for _, d := range list {
resp := toDownloadResponse(d)
if resp.BatchID == "" {
resp.BatchID = batchID
}
responses = append(responses, resp)
}
writeJSON(w, http.StatusAccepted, downloadsListResponse{Downloads: responses, Skipped: skipped})
return
}
dl, err := h.svc.Create(r.Context(), userID, createReq, filter)
if err != nil {
h.writeDownloadError(w, err)
return
}
writeJSON(w, http.StatusAccepted, toDownloadResponse(dl))
}
// HandleListDownloads handles GET /downloads. With a device header it returns
// the calling device's managed entries; otherwise the user's ephemeral rows.
func (h *DownloadHandler) HandleListDownloads(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
if h.svc == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Downloads not configured")
return
}
profileID, deviceID, _, _ := managedIdentity(r)
list, err := h.svc.List(r.Context(), userID, profileID, deviceID)
if err != nil {
if errors.Is(err, downloads.ErrProfileRequired) {
writeError(w, http.StatusBadRequest, "profile_required", "A profile is required for managed downloads")
return
}
slog.ErrorContext(r.Context(), "failed to list downloads", "component", "api", "user_id", userID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list downloads")
return
}
responses := make([]downloadResponse, 0, len(list))
for _, d := range list {
responses = append(responses, toDownloadResponse(d))
}
writeJSON(w, http.StatusOK, downloadsListResponse{Downloads: responses})
}
// HandleDeleteDownload handles DELETE /downloads/{id}.
func (h *DownloadHandler) HandleDeleteDownload(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
id := chi.URLParam(r, "id")
if id == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Download ID is required")
return
}
if h.svc == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Downloads not configured")
return
}
profileID, deviceID, _, _ := managedIdentity(r)
if err := h.svc.Delete(r.Context(), userID, profileID, deviceID, id); err != nil {
if errors.Is(err, downloads.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Download not found")
return
}
if errors.Is(err, downloads.ErrProfileRequired) {
writeError(w, http.StatusBadRequest, "profile_required", "A profile is required for managed downloads")
return
}
slog.ErrorContext(r.Context(), "failed to delete download", "component", "api", "download_id", id, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete download")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandlePatchDownload handles PATCH /downloads/{id} — a managed-only endpoint
// where a client confirms local state (downloading|completed).
func (h *DownloadHandler) HandlePatchDownload(w http.ResponseWriter, r *http.Request) {
userID, profileID, deviceID, _, _, ok := h.requireManaged(w, r)
if !ok {
return
}
id := chi.URLParam(r, "id")
if id == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Download ID is required")
return
}
var req patchDownloadRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if err := h.svc.PatchStatus(r.Context(), userID, profileID, deviceID, id, req.Status); err != nil {
switch {
case errors.Is(err, downloads.ErrNotFound):
writeError(w, http.StatusNotFound, "not_found", "Download not found")
case errors.Is(err, downloads.ErrInvalidStatus):
writeError(w, http.StatusBadRequest, "invalid_status", "status must be downloading or completed")
case errors.Is(err, downloads.ErrProfileRequired):
writeError(w, http.StatusBadRequest, "profile_required", "A profile is required for managed downloads")
default:
slog.ErrorContext(r.Context(), "failed to patch download", "component", "api", "download_id", id, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update download")
}
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleDownloadFile handles GET /downloads/{id}/file.
func (h *DownloadHandler) HandleDownloadFile(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
id := chi.URLParam(r, "id")
if id == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Download ID is required")
return
}
if h.svc == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Downloads not configured")
return
}
profileID, deviceID, _, _ := managedIdentity(r)
filter := requestAccessFilter(r)
// Full media downloads outlive the server's absolute WriteTimeout; roll
// the write deadline with progress instead.
sw := httpstream.NewRollingDeadlineWriter(w)
if err := h.svc.ServeFile(r.Context(), sw, r, userID, profileID, deviceID, id, filter); err != nil {
if errors.Is(err, downloads.ErrNotFound) || errors.Is(err, catalog.ErrItemNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Download not found")
return
}
if errors.Is(err, downloads.ErrDownloadNotActive) {
writeError(w, http.StatusConflict, "download_inactive", "This download is no longer active")
return
}
if errors.Is(err, downloads.ErrProfileRequired) {
writeError(w, http.StatusBadRequest, "profile_required", "A profile is required for managed downloads")
return
}
if errors.Is(err, downloads.ErrFeatureDisabled) || errors.Is(err, downloads.ErrDownloadNotAllowed) {
writeError(w, http.StatusForbidden, "forbidden", "You are not allowed to download")
return
}
slog.ErrorContext(r.Context(), "failed to serve download file", "component", "api", "download_id", id, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to serve download")
return
}
}
// HandleDirectDownload handles GET /direct-download?file_id=N. A legacy
// format=original query is accepted, but direct downloads remain original-only.
func (h *DownloadHandler) HandleDirectDownload(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
if h.svc == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Downloads not configured")
return
}
fileIDStr := r.URL.Query().Get("file_id")
fileID, err := strconv.Atoi(fileIDStr)
if err != nil || fileID <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "file_id query parameter is required")
return
}
filter := requestAccessFilter(r)
if err := h.svc.ServeDirect(r.Context(), w, r, userID, fileID, r.URL.Query().Get("format"), filter); err != nil {
h.writeDownloadError(w, err)
return
}
}
// requireManaged validates a managed (device-scoped) request: authentication, a
// configured service, and the device + profile identity (device_id from the
// X-Silo-Device-Id header only, never the body). On failure it writes the error
// response and returns ok=false. Shared by every managed-only endpoint — the
// offline assets and the series-monitoring subscriptions.
func (h *DownloadHandler) requireManaged(w http.ResponseWriter, r *http.Request) (userID int, profileID, deviceID, deviceName, devicePlatform string, ok bool) {
userID = apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
if h.svc == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Downloads not configured")
return
}
profileID, deviceID, deviceName, devicePlatform = managedIdentity(r)
if deviceID == "" {
writeError(w, http.StatusBadRequest, "device_id_required", "X-Silo-Device-Id header is required")
return
}
if profileID == "" {
writeError(w, http.StatusBadRequest, "profile_required", "A profile is required for managed downloads")
return
}
return userID, profileID, deviceID, deviceName, devicePlatform, true
}
// managedAssetIdentity is requireManaged plus the URL {id} param, for the offline
// asset endpoints (manifest/artwork/subtitle).
func (h *DownloadHandler) managedAssetIdentity(w http.ResponseWriter, r *http.Request) (userID int, profileID, deviceID, id string, ok bool) {
userID, profileID, deviceID, _, _, ok = h.requireManaged(w, r)
if !ok {
return userID, profileID, deviceID, "", false
}
id = chi.URLParam(r, "id")
if id == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Download ID is required")
return userID, profileID, deviceID, "", false
}
return userID, profileID, deviceID, id, true
}
// HandleManifest handles GET /downloads/{id}/manifest (managed-only).
func (h *DownloadHandler) HandleManifest(w http.ResponseWriter, r *http.Request) {
userID, profileID, deviceID, id, ok := h.managedAssetIdentity(w, r)
if !ok {
return
}
manifest, err := h.svc.BuildManifest(r.Context(), userID, profileID, deviceID, id, requestAccessFilter(r))
if err != nil {
h.writeAssetError(w, "manifest", id, err)
return
}
writeJSON(w, http.StatusOK, manifest)
}
// HandleBatchManifests handles GET /downloads/batches/{batch_id}/manifests
// (managed-only).
func (h *DownloadHandler) HandleBatchManifests(w http.ResponseWriter, r *http.Request) {
userID, profileID, deviceID, _, _, ok := h.requireManaged(w, r)
if !ok {
return
}
batchID := chi.URLParam(r, "batch_id")
if batchID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Batch ID is required")
return
}
manifests, skipped, err := h.svc.BuildBatchManifests(r.Context(), userID, profileID, deviceID, batchID, requestAccessFilter(r))
if err != nil {
h.writeAssetError(w, "batch_manifests", batchID, err)
return
}
writeJSON(w, http.StatusOK, batchManifestsResponse{Manifests: manifests, Skipped: skipped})
}
// HandleArtwork handles GET /downloads/{id}/artwork/{kind} (managed-only).
func (h *DownloadHandler) HandleArtwork(w http.ResponseWriter, r *http.Request) {
userID, profileID, deviceID, id, ok := h.managedAssetIdentity(w, r)
if !ok {
return
}
kind := chi.URLParam(r, "kind")
if err := h.svc.ServeArtwork(r.Context(), w, r, userID, profileID, deviceID, id, kind, requestAccessFilter(r)); err != nil {
h.writeAssetError(w, "artwork", id, err)
return
}
}
// HandleSubtitle handles GET /downloads/{id}/subtitles/{ref} (managed-only).
func (h *DownloadHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
userID, profileID, deviceID, id, ok := h.managedAssetIdentity(w, r)
if !ok {
return
}
ref := chi.URLParam(r, "ref")
if err := h.svc.ServeSubtitle(r.Context(), w, r, userID, profileID, deviceID, id, ref, requestAccessFilter(r)); err != nil {
h.writeAssetError(w, "subtitle", id, err)
return
}
}
// writeAssetError maps offline asset (manifest/artwork/subtitle) errors. Access
// denials and missing assets collapse to 404 so a download id never reveals the
// existence of out-of-scope content.
func (h *DownloadHandler) writeAssetError(w http.ResponseWriter, asset, id string, err error) {
switch {
case errors.Is(err, downloads.ErrNotFound),
errors.Is(err, downloads.ErrAssetNotFound),
errors.Is(err, catalog.ErrItemNotFound):
writeError(w, http.StatusNotFound, "not_found", "Not found")
case errors.Is(err, downloads.ErrInvalidSubtitleRef):
writeError(w, http.StatusBadRequest, "invalid_subtitle_ref", "Invalid subtitle reference")
case errors.Is(err, downloads.ErrDownloadNotActive):
writeError(w, http.StatusConflict, "download_inactive", "This download is no longer active")
case errors.Is(err, downloads.ErrProfileRequired):
writeError(w, http.StatusBadRequest, "profile_required", "A profile is required for managed downloads")
case errors.Is(err, downloads.ErrManifestUnavailable):
writeError(w, http.StatusServiceUnavailable, "unavailable", "Offline assets are not configured")
case errors.Is(err, downloads.ErrFeatureDisabled), errors.Is(err, downloads.ErrDownloadNotAllowed):
writeError(w, http.StatusForbidden, "forbidden", "You are not allowed to download")
default:
slog.Error("failed to serve download asset", "asset", asset, "download_id", id, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to serve download asset")
}
}
func (h *DownloadHandler) writeDownloadError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, downloads.ErrFeatureDisabled):
writeError(w, http.StatusForbidden, "feature_disabled", "Downloads are disabled")
case errors.Is(err, downloads.ErrDownloadNotAllowed):
writeError(w, http.StatusForbidden, "forbidden", "You are not allowed to download")
case errors.Is(err, downloads.ErrTranscodeDisabled):
writeError(w, http.StatusForbidden, "transcode_disabled", "Download transcoding is disabled")
case errors.Is(err, downloads.ErrInvalidQuality):
writeError(w, http.StatusBadRequest, "invalid_quality", "Unknown download quality")
case errors.Is(err, downloads.ErrProfileRequired):
writeError(w, http.StatusBadRequest, "profile_required", "A profile is required for managed downloads")
case errors.Is(err, downloads.ErrBulkQualityUnavailable):
writeError(w, http.StatusNotImplemented, "bulk_quality_unavailable", "Bitrate quality is not available for bulk downloads yet")
case errors.Is(err, downloads.ErrQualityUnavailable):
writeError(w, http.StatusNotImplemented, "quality_unavailable", "This download quality is not available")
case errors.Is(err, downloads.ErrFormatUnavailable):
writeError(w, http.StatusNotImplemented, "format_unavailable", "This download format is not available yet")
case errors.Is(err, downloads.ErrConcurrentLimitReached):
writeError(w, http.StatusTooManyRequests, "download_limit_exceeded", "Maximum concurrent downloads reached")
case errors.Is(err, downloads.ErrPeriodLimitReached):
writeError(w, http.StatusTooManyRequests, "download_quota_exceeded", "Download quota exceeded for this period")
case errors.Is(err, downloads.ErrNoDownloadableEpisodes):
writeError(w, http.StatusNotFound, "no_downloadable_episodes", "No downloadable episodes found")
case errors.Is(err, catalog.ErrItemNotFound):
writeError(w, http.StatusNotFound, "not_found", "Media item not found")
default:
slog.Error("download operation failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to process download")
}
}