Files
silo-server/internal/download/service.go
T

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