519 lines
14 KiB
Go
519 lines
14 KiB
Go
package download
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
"github.com/Silo-Server/silo-server/internal/config"
|
|
"github.com/Silo-Server/silo-server/internal/idgen"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
"github.com/Silo-Server/silo-server/internal/playback"
|
|
)
|
|
|
|
// FileResolver looks up media files by various keys.
|
|
type FileResolver interface {
|
|
GetByID(ctx context.Context, id int) (*models.MediaFile, error)
|
|
GetByContentID(ctx context.Context, contentID string) ([]*models.MediaFile, error)
|
|
GetByEpisodeID(ctx context.Context, episodeID string) ([]*models.MediaFile, error)
|
|
}
|
|
|
|
// ItemResolver looks up media items.
|
|
type ItemResolver interface {
|
|
GetByID(ctx context.Context, contentID string) (*models.MediaItem, error)
|
|
}
|
|
|
|
// EpisodeResolver lists episodes for a series.
|
|
type EpisodeResolver interface {
|
|
ListBySeries(ctx context.Context, seriesID string) ([]*models.Episode, error)
|
|
}
|
|
|
|
// UserResolver looks up users.
|
|
type UserResolver interface {
|
|
GetByID(ctx context.Context, id int) (*models.User, error)
|
|
}
|
|
|
|
// ItemAccessChecker checks library/content-rating access.
|
|
type ItemAccessChecker interface {
|
|
EnsureAccessible(ctx context.Context, contentID string, filter catalog.AccessFilter) error
|
|
}
|
|
|
|
// SettingsReader loads all server settings as a flat map.
|
|
type SettingsReader interface {
|
|
GetAll(ctx context.Context) (map[string]string, error)
|
|
}
|
|
|
|
const configCacheTTL = 30 * time.Second
|
|
|
|
// Service orchestrates download permission checks, quota enforcement,
|
|
// file resolution, and file serving.
|
|
type Service struct {
|
|
repo *Repository
|
|
bandwidth *BandwidthManager
|
|
limiter *QuantityLimiter
|
|
fileRepo FileResolver
|
|
itemRepo ItemResolver
|
|
episodeRepo EpisodeResolver
|
|
userRepo UserResolver
|
|
itemAccess ItemAccessChecker
|
|
settings SettingsReader
|
|
|
|
cfgMu sync.RWMutex
|
|
cfg config.DownloadConfig
|
|
cfgLoadedAt time.Time
|
|
}
|
|
|
|
// NewService creates a new download service with the given dependencies.
|
|
func NewService(
|
|
repo *Repository,
|
|
bandwidth *BandwidthManager,
|
|
limiter *QuantityLimiter,
|
|
fileRepo FileResolver,
|
|
itemRepo ItemResolver,
|
|
episodeRepo EpisodeResolver,
|
|
userRepo UserResolver,
|
|
itemAccess ItemAccessChecker,
|
|
settings SettingsReader,
|
|
initialCfg *config.DownloadConfig,
|
|
) *Service {
|
|
s := &Service{
|
|
repo: repo,
|
|
bandwidth: bandwidth,
|
|
limiter: limiter,
|
|
fileRepo: fileRepo,
|
|
itemRepo: itemRepo,
|
|
episodeRepo: episodeRepo,
|
|
userRepo: userRepo,
|
|
itemAccess: itemAccess,
|
|
settings: settings,
|
|
}
|
|
if initialCfg != nil {
|
|
s.cfg = *initialCfg
|
|
s.cfgLoadedAt = time.Now()
|
|
}
|
|
return s
|
|
}
|
|
|
|
// loadConfig returns the current download config, refreshing from DB if stale.
|
|
func (s *Service) loadConfig(ctx context.Context) config.DownloadConfig {
|
|
s.cfgMu.RLock()
|
|
if time.Since(s.cfgLoadedAt) < configCacheTTL {
|
|
cfg := s.cfg
|
|
s.cfgMu.RUnlock()
|
|
return cfg
|
|
}
|
|
s.cfgMu.RUnlock()
|
|
|
|
s.cfgMu.Lock()
|
|
defer s.cfgMu.Unlock()
|
|
|
|
// Double-check after acquiring write lock.
|
|
if time.Since(s.cfgLoadedAt) < configCacheTTL {
|
|
return s.cfg
|
|
}
|
|
|
|
if s.settings == nil {
|
|
return s.cfg
|
|
}
|
|
|
|
allSettings, err := s.settings.GetAll(ctx)
|
|
if err != nil {
|
|
slog.Warn("failed to reload download config from DB, using cached", "error", err)
|
|
return s.cfg
|
|
}
|
|
|
|
newFullCfg, err := config.LoadFromDB(allSettings)
|
|
if err != nil {
|
|
slog.Warn("failed to parse download config from DB, using cached", "error", err)
|
|
return s.cfg
|
|
}
|
|
|
|
oldCfg := s.cfg
|
|
s.cfg = newFullCfg.Download
|
|
s.cfgLoadedAt = time.Now()
|
|
|
|
// Update bandwidth manager if limits changed.
|
|
if s.bandwidth != nil && (oldCfg.ServerBandwidthBPS != s.cfg.ServerBandwidthBPS || oldCfg.UserBandwidthBPS != s.cfg.UserBandwidthBPS) {
|
|
s.bandwidth.Reload(s.cfg.ServerBandwidthBPS, s.cfg.UserBandwidthBPS)
|
|
slog.Info("download bandwidth config reloaded", "server_bps", s.cfg.ServerBandwidthBPS, "user_bps", s.cfg.UserBandwidthBPS)
|
|
}
|
|
|
|
// Update quantity limiter if limits changed.
|
|
if s.limiter != nil && (oldCfg.MaxConcurrentPerUser != s.cfg.MaxConcurrentPerUser || oldCfg.MaxPerPeriod != s.cfg.MaxPerPeriod || oldCfg.PeriodDuration != s.cfg.PeriodDuration) {
|
|
s.limiter.Reload(s.cfg.MaxConcurrentPerUser, s.cfg.MaxPerPeriod, s.cfg.PeriodDuration)
|
|
slog.Info("download quantity limits reloaded", "max_concurrent", s.cfg.MaxConcurrentPerUser, "max_per_period", s.cfg.MaxPerPeriod, "period", s.cfg.PeriodDuration)
|
|
}
|
|
|
|
return s.cfg
|
|
}
|
|
|
|
// CreateRequest holds the parameters for creating a download.
|
|
type CreateRequest struct {
|
|
ContentID string
|
|
EpisodeID string
|
|
FileID int
|
|
}
|
|
|
|
// CreateQueued creates a queued download for a single item (movie or episode).
|
|
func (s *Service) CreateQueued(ctx context.Context, userID int, req CreateRequest, filter catalog.AccessFilter) (*Download, error) {
|
|
if err := s.checkEnabled(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
user, err := s.userRepo.GetByID(ctx, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading user: %w", err)
|
|
}
|
|
if !user.DownloadAllowed {
|
|
return nil, ErrDownloadNotAllowed
|
|
}
|
|
|
|
file, err := s.resolveFile(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := s.itemAccess.EnsureAccessible(ctx, file.ContentID, filter); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := s.limiter.Check(ctx, userID, 1); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
id, err := idgen.NextID()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generating download ID: %w", err)
|
|
}
|
|
|
|
now := time.Now()
|
|
d := &Download{
|
|
ID: id,
|
|
UserID: userID,
|
|
MediaFileID: file.ID,
|
|
ContentID: file.ContentID,
|
|
EpisodeID: file.EpisodeID,
|
|
Kind: KindQueued,
|
|
Status: StatusQueued,
|
|
FileSize: file.FileSize,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
if err := s.repo.Create(ctx, d); err != nil {
|
|
return nil, err
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// CreateQueuedBatch creates download records for all episodes in a series.
|
|
// Returns the created downloads and a shared batch ID.
|
|
func (s *Service) CreateQueuedBatch(ctx context.Context, userID int, seriesContentID string, filter catalog.AccessFilter) ([]*Download, string, error) {
|
|
if err := s.checkEnabled(ctx); err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
user, err := s.userRepo.GetByID(ctx, userID)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("loading user: %w", err)
|
|
}
|
|
if !user.DownloadAllowed {
|
|
return nil, "", ErrDownloadNotAllowed
|
|
}
|
|
|
|
item, err := s.itemRepo.GetByID(ctx, seriesContentID)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("loading series: %w", err)
|
|
}
|
|
if item.Type != "series" {
|
|
return nil, "", fmt.Errorf("content_id is not a series")
|
|
}
|
|
|
|
if err := s.itemAccess.EnsureAccessible(ctx, seriesContentID, filter); err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
episodes, err := s.episodeRepo.ListBySeries(ctx, seriesContentID)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("listing episodes: %w", err)
|
|
}
|
|
|
|
var downloads []*Download
|
|
now := time.Now()
|
|
|
|
for _, ep := range episodes {
|
|
files, err := s.fileRepo.GetByEpisodeID(ctx, ep.ContentID)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("resolving files for episode %s: %w", ep.ContentID, err)
|
|
}
|
|
if len(files) == 0 {
|
|
continue // skip episodes with no files
|
|
}
|
|
file := pickBestFile(files)
|
|
|
|
id, err := idgen.NextID()
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("generating download ID: %w", err)
|
|
}
|
|
|
|
downloads = append(downloads, &Download{
|
|
ID: id,
|
|
UserID: userID,
|
|
MediaFileID: file.ID,
|
|
ContentID: seriesContentID,
|
|
EpisodeID: ep.ContentID,
|
|
Kind: KindQueued,
|
|
Status: StatusQueued,
|
|
FileSize: file.FileSize,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
})
|
|
}
|
|
|
|
if len(downloads) == 0 {
|
|
return nil, "", fmt.Errorf("no downloadable episodes found")
|
|
}
|
|
|
|
if err := s.limiter.Check(ctx, userID, len(downloads)); err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
batchID, err := idgen.NextID()
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("generating batch ID: %w", err)
|
|
}
|
|
for _, d := range downloads {
|
|
d.BatchID = batchID
|
|
}
|
|
|
|
if err := s.repo.CreateBatch(ctx, downloads); err != nil {
|
|
return nil, "", err
|
|
}
|
|
return downloads, batchID, nil
|
|
}
|
|
|
|
// ServeDirect validates permissions and serves a file directly for browser download.
|
|
// No persistent download record is created.
|
|
func (s *Service) ServeDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, userID int, fileID int, filter catalog.AccessFilter) error {
|
|
if err := s.checkEnabled(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
user, err := s.userRepo.GetByID(ctx, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("loading user: %w", err)
|
|
}
|
|
if !user.DownloadAllowed {
|
|
return ErrDownloadNotAllowed
|
|
}
|
|
|
|
file, err := s.fileRepo.GetByID(ctx, fileID)
|
|
if err != nil {
|
|
return fmt.Errorf("loading media file: %w", err)
|
|
}
|
|
if file == nil || file.MissingSince != nil {
|
|
return catalog.ErrItemNotFound
|
|
}
|
|
|
|
if err := s.itemAccess.EnsureAccessible(ctx, file.ContentID, filter); err != nil {
|
|
return err
|
|
}
|
|
|
|
return s.serveFileDownload(ctx, w, r, file, userID)
|
|
}
|
|
|
|
// ServeFile serves a queued download's file, verifying ownership and current policy.
|
|
func (s *Service) ServeFile(ctx context.Context, w http.ResponseWriter, r *http.Request, userID int, downloadID string) error {
|
|
// Re-check policy — admin may have disabled downloads or revoked permission
|
|
// after this download was queued.
|
|
if err := s.checkEnabled(ctx); err != nil {
|
|
return err
|
|
}
|
|
user, err := s.userRepo.GetByID(ctx, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("loading user: %w", err)
|
|
}
|
|
if !user.DownloadAllowed {
|
|
return ErrDownloadNotAllowed
|
|
}
|
|
|
|
dl, err := s.repo.GetByID(ctx, downloadID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if dl.UserID != userID {
|
|
return ErrNotFound // don't reveal existence
|
|
}
|
|
if dl.Status == StatusCancelled || dl.Status == StatusFailed {
|
|
return fmt.Errorf("download is %s: %w", dl.Status, ErrDownloadNotActive)
|
|
}
|
|
|
|
file, err := s.fileRepo.GetByID(ctx, dl.MediaFileID)
|
|
if err != nil {
|
|
return fmt.Errorf("loading media file: %w", err)
|
|
}
|
|
if file == nil || file.MissingSince != nil {
|
|
return catalog.ErrItemNotFound
|
|
}
|
|
|
|
// Atomically transition queued → downloading. If another request already
|
|
// claimed this download, return a conflict error.
|
|
if dl.Status == StatusQueued {
|
|
if err := s.repo.TransitionStatus(ctx, dl.ID, StatusQueued, StatusDownloading, 0, nil); err != nil {
|
|
if errors.Is(err, ErrStatusConflict) {
|
|
return fmt.Errorf("download already in progress: %w", ErrDownloadNotActive)
|
|
}
|
|
slog.Warn("failed to transition download to downloading", "download_id", dl.ID, "error", err)
|
|
}
|
|
}
|
|
|
|
if err := s.serveFileDownload(ctx, w, r, file, userID); err != nil {
|
|
if updateErr := s.repo.UpdateStatus(ctx, dl.ID, StatusFailed, 0, nil); updateErr != nil {
|
|
slog.Error("failed to mark download as failed", "download_id", dl.ID, "error", updateErr)
|
|
}
|
|
return err
|
|
}
|
|
|
|
now := time.Now()
|
|
if err := s.repo.UpdateStatus(ctx, dl.ID, StatusCompleted, file.FileSize, &now); err != nil {
|
|
slog.Error("failed to mark download as completed", "download_id", dl.ID, "error", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// List returns all downloads for a user.
|
|
func (s *Service) List(ctx context.Context, userID int) ([]*Download, error) {
|
|
return s.repo.ListByUser(ctx, userID)
|
|
}
|
|
|
|
// Cancel cancels a queued/active download or deletes a completed/failed one.
|
|
func (s *Service) Cancel(ctx context.Context, userID int, downloadID string) error {
|
|
dl, err := s.repo.GetByID(ctx, downloadID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if dl.UserID != userID {
|
|
return ErrNotFound
|
|
}
|
|
|
|
switch dl.Status {
|
|
case StatusQueued, StatusDownloading:
|
|
return s.repo.CancelByID(ctx, downloadID, userID)
|
|
default:
|
|
return s.repo.Delete(ctx, downloadID, userID)
|
|
}
|
|
}
|
|
|
|
func (s *Service) checkEnabled(ctx context.Context) error {
|
|
cfg := s.loadConfig(ctx)
|
|
if !cfg.Enabled {
|
|
return ErrFeatureDisabled
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) resolveFile(ctx context.Context, req CreateRequest) (*models.MediaFile, error) {
|
|
if req.FileID > 0 {
|
|
file, err := s.fileRepo.GetByID(ctx, req.FileID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading media file: %w", err)
|
|
}
|
|
if file == nil || file.MissingSince != nil {
|
|
return nil, catalog.ErrItemNotFound
|
|
}
|
|
return file, nil
|
|
}
|
|
|
|
var files []*models.MediaFile
|
|
var err error
|
|
if req.EpisodeID != "" {
|
|
files, err = s.fileRepo.GetByEpisodeID(ctx, req.EpisodeID)
|
|
} else {
|
|
files, err = s.fileRepo.GetByContentID(ctx, req.ContentID)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolving files: %w", err)
|
|
}
|
|
if len(files) == 0 {
|
|
return nil, catalog.ErrItemNotFound
|
|
}
|
|
|
|
return pickBestFile(files), nil
|
|
}
|
|
|
|
func (s *Service) serveFileDownload(ctx context.Context, w http.ResponseWriter, r *http.Request, file *models.MediaFile, userID int) error {
|
|
f, err := os.Open(file.FilePath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return catalog.ErrItemNotFound
|
|
}
|
|
return fmt.Errorf("opening file: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
stat, err := f.Stat()
|
|
if err != nil {
|
|
return fmt.Errorf("stat file: %w", err)
|
|
}
|
|
|
|
filename := sanitizeFilename(filepath.Base(file.FilePath))
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
|
w.Header().Set("Content-Type", playback.MimeFromExtension(file.FilePath))
|
|
|
|
var reader io.ReadSeeker = f
|
|
if s.bandwidth != nil {
|
|
reader = s.bandwidth.ThrottledReader(ctx, f, userID)
|
|
}
|
|
|
|
http.ServeContent(w, r, stat.Name(), stat.ModTime(), reader)
|
|
return nil
|
|
}
|
|
|
|
// pickBestFile selects the highest-resolution file from a list.
|
|
func pickBestFile(files []*models.MediaFile) *models.MediaFile {
|
|
if len(files) == 1 {
|
|
return files[0]
|
|
}
|
|
best := files[0]
|
|
for _, f := range files[1:] {
|
|
if resolutionRank(f.Resolution) > resolutionRank(best.Resolution) {
|
|
best = f
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
func resolutionRank(res string) int {
|
|
switch strings.ToLower(res) {
|
|
case "2160p":
|
|
return 4
|
|
case "1080p":
|
|
return 3
|
|
case "720p":
|
|
return 2
|
|
case "480p":
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func sanitizeFilename(name string) string {
|
|
return strings.Map(func(r rune) rune {
|
|
switch r {
|
|
case '/', '\\', '"', '<', '>', '|', '?', '*', ':':
|
|
return '_'
|
|
}
|
|
return r
|
|
}, name)
|
|
}
|