Files

280 lines
6.8 KiB
Go
Executable File

package jobs
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"os"
"path"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"ddc/internal/config"
"ddc/internal/diskspace"
"ddc/internal/fetch"
"ddc/internal/files"
"ddc/internal/guard"
"ddc/internal/history"
"ddc/internal/libraries"
"ddc/internal/preferences"
)
var (
ErrJobNotFound = errors.New("download not found")
ErrJobBusy = errors.New("download is busy, try again when it is ready")
ErrInvalidRequest = errors.New("invalid request")
)
type Manager struct {
settings config.Settings
preferences *preferences.Store
history *history.Store
policy *guard.Policy
downloader *fetch.Downloader
rootContext context.Context
cancelAll context.CancelFunc
slots slotLimiter
mutex sync.RWMutex
jobs map[string]*Job
}
func NewManager(settings config.Settings, store *preferences.Store, historyStore *history.Store) *Manager {
policy := guard.NewPolicy(store.Get().AllowedPorts)
rootContext, cancelAll := context.WithCancel(context.Background())
return &Manager{
settings: settings,
preferences: store,
history: historyStore,
policy: policy,
downloader: fetch.NewDownloader(policy),
rootContext: rootContext,
cancelAll: cancelAll,
jobs: make(map[string]*Job),
}
}
func (manager *Manager) Destinations() []libraries.Destination {
return libraries.Discover(manager.settings.LibraryRoots)
}
func (manager *Manager) Preferences() preferences.Preferences {
return manager.preferences.Get()
}
func (manager *Manager) UpdatePreferences(next preferences.Preferences) error {
downloadPaths, err := manager.normalizeDownloadPaths(next.DownloadPaths)
if err != nil {
return err
}
next.DownloadPaths = downloadPaths
if err := manager.preferences.Update(next); err != nil {
return err
}
manager.policy.SetAllowedPorts(next.AllowedPorts)
slog.Debug("preferences saved",
"parallel_downloads", next.MaxConcurrentDownloads,
"connections_per_download", next.ConnectionsPerDownload,
"min_free_gb", next.MinFreeGB,
"download_paths", len(next.DownloadPaths),
)
return nil
}
func (manager *Manager) concurrencyLimit() int {
return manager.preferences.Get().MaxConcurrentDownloads
}
func (manager *Manager) reservedBytes() int64 {
return manager.preferences.Get().MinFreeBytes()
}
func (manager *Manager) AvailableBytes() (int64, error) {
return diskspace.Available(manager.settings.DownloadDir)
}
type CreateRequest struct {
URL string `json:"url"`
FileName string `json:"fileName"`
AutoExtract bool `json:"autoExtract"`
Destination string `json:"destination"`
Subfolder string `json:"subfolder"`
}
func (manager *Manager) Create(request CreateRequest) (Snapshot, error) {
target, err := manager.policy.ValidateURL(request.URL)
if err != nil {
return Snapshot{}, invalid(err)
}
requestedName := fetch.SanitizeFileName(request.FileName)
if strings.TrimSpace(request.FileName) != "" && requestedName == "" {
return Snapshot{}, invalid(errors.New("file name is not allowed"))
}
moveTarget, err := manager.resolveTarget(request.Destination, request.Subfolder)
if err != nil {
return Snapshot{}, err
}
if err := diskspace.EnsureFree(manager.settings.DownloadDir, 0, manager.reservedBytes()); err != nil {
return Snapshot{}, err
}
id, err := newJobID()
if err != nil {
return Snapshot{}, err
}
directory := filepath.Join(manager.settings.DownloadDir, id)
if err := os.Mkdir(directory, 0o755); err != nil {
return Snapshot{}, err
}
job := manager.newJob(id, directory, time.Now())
job.sourceURL = target.String()
job.displayURL = target.Redacted()
job.requestedName = requestedName
job.autoExtract = request.AutoExtract
job.target = moveTarget
job.status = StatusQueued
job.fileName = initialFileName(requestedName, target.Path, target.Host)
job.workers.Add(1)
manager.store(job)
job.saveRecord()
job.logger().Info("download added",
"url", job.displayURL,
"file_name", requestedName,
"target", job.snapshot().MoveTarget,
)
manager.launchDownload(job)
return job.snapshot(), nil
}
func (manager *Manager) List() []Snapshot {
manager.mutex.RLock()
snapshots := make([]Snapshot, 0, len(manager.jobs))
for _, job := range manager.jobs {
snapshot := job.snapshot()
if snapshot.Status == StatusQueued {
snapshot.QueuePosition = manager.slots.position(job.id)
}
snapshots = append(snapshots, snapshot)
}
manager.mutex.RUnlock()
sort.Slice(snapshots, func(first, second int) bool {
return snapshots[first].CreatedAt.After(snapshots[second].CreatedAt)
})
return snapshots
}
func (manager *Manager) Files(id string) ([]files.Node, error) {
job, err := manager.lookup(id)
if err != nil {
return nil, err
}
if job.currentStatus() != StatusReady {
return nil, ErrJobBusy
}
return files.BuildTree(job.directory)
}
func (manager *Manager) Delete(id string) error {
job, err := manager.lookup(id)
if err != nil {
return err
}
if !job.markDeleted() {
return ErrJobBusy
}
job.cancel()
job.workers.Wait()
manager.mutex.Lock()
delete(manager.jobs, id)
manager.mutex.Unlock()
if err := os.RemoveAll(job.directory); err != nil {
job.logger().Error("could not remove download files", "directory", job.directory, "error", err)
return err
}
job.logger().Info("download removed")
return nil
}
func (manager *Manager) Shutdown(ctx context.Context) {
manager.cancelAll()
manager.mutex.RLock()
allJobs := make([]*Job, 0, len(manager.jobs))
for _, job := range manager.jobs {
allJobs = append(allJobs, job)
}
manager.mutex.RUnlock()
finished := make(chan struct{})
go func() {
for _, job := range allJobs {
job.workers.Wait()
}
close(finished)
}()
select {
case <-finished:
case <-ctx.Done():
slog.Warn("stopped waiting for downloads to save progress", "error", ctx.Err())
}
}
func (manager *Manager) newJob(id, directory string, createdAt time.Time) *Job {
jobContext, cancel := context.WithCancel(manager.rootContext)
return &Job{
id: id,
directory: directory,
createdAt: createdAt,
jobContext: jobContext,
cancel: cancel,
}
}
func (manager *Manager) store(job *Job) {
manager.mutex.Lock()
defer manager.mutex.Unlock()
manager.jobs[job.id] = job
}
func (manager *Manager) lookup(id string) (*Job, error) {
manager.mutex.RLock()
defer manager.mutex.RUnlock()
job, found := manager.jobs[id]
if !found {
return nil, ErrJobNotFound
}
return job, nil
}
func newJobID() (string, error) {
buffer := make([]byte, 16)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
return hex.EncodeToString(buffer), nil
}
func initialFileName(requestedName, urlPath, host string) string {
if requestedName != "" {
return requestedName
}
if name := fetch.SanitizeFileName(path.Base(urlPath)); name != "" {
return name
}
return host
}
func invalid(err error) error {
return fmt.Errorf("%w: %w", ErrInvalidRequest, err)
}