Add resumable downloads, download paths, queue controls and history
This commit is contained in:
1 parent
434f6441da
commit
6027cfabb9
43 files changed
+2138
-291
No files matched your search
@@ -0,0 +1,122 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
|
||||
"ddc/internal/extract"
|
||||
"ddc/internal/fetch"
|
||||
)
|
||||
|
||||
func (manager *Manager) Pause(id string) error {
|
||||
job, err := manager.lookup(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !job.pause() {
|
||||
return ErrJobBusy
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *Manager) Resume(id string) error {
|
||||
job, err := manager.lookup(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !job.begin(StatusQueued, StatusPaused, StatusFailed) {
|
||||
return ErrJobBusy
|
||||
}
|
||||
job.saveRecord()
|
||||
manager.launchDownload(job)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *Manager) Reorder(id, direction string) error {
|
||||
if direction != queueFirst && direction != queueUp && direction != queueDown {
|
||||
return invalid(errors.New("direction must be first, up or down"))
|
||||
}
|
||||
job, err := manager.lookup(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if job.currentStatus() != StatusQueued || !manager.slots.reorder(id, direction) {
|
||||
return ErrJobBusy
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *Manager) launchDownload(job *Job) {
|
||||
runContext, cancelRun := job.startRun()
|
||||
manager.slots.enqueue(job.id)
|
||||
go func() {
|
||||
defer job.workers.Done()
|
||||
defer cancelRun(nil)
|
||||
manager.download(job, runContext)
|
||||
}()
|
||||
}
|
||||
|
||||
func (manager *Manager) download(job *Job, runContext context.Context) {
|
||||
if !manager.slots.acquire(runContext, job.id, manager.concurrencyLimit) {
|
||||
manager.handleDownloadError(job, runContext, context.Cause(runContext))
|
||||
return
|
||||
}
|
||||
defer manager.slots.release()
|
||||
|
||||
job.setStatus(StatusDownloading, "")
|
||||
filePath, err := manager.downloader.Download(runContext, manager.fetchRequest(job))
|
||||
if err != nil {
|
||||
manager.handleDownloadError(job, runContext, err)
|
||||
return
|
||||
}
|
||||
job.markDownloaded(filepath.Base(filePath))
|
||||
|
||||
if job.autoExtract && extract.IsArchive(filePath) {
|
||||
job.setStatus(StatusExtracting, "")
|
||||
destination, err := manager.extractArchive(job, filePath)
|
||||
if err != nil {
|
||||
manager.finishWithError(job, StatusReady, "Extraction failed: ", err)
|
||||
job.saveRecord()
|
||||
return
|
||||
}
|
||||
job.SetFileName(filepath.Base(destination))
|
||||
}
|
||||
|
||||
job.setStatus(StatusReady, "")
|
||||
job.saveRecord()
|
||||
manager.moveToTarget(job)
|
||||
}
|
||||
|
||||
func (manager *Manager) fetchRequest(job *Job) fetch.Request {
|
||||
current := manager.preferences.Get()
|
||||
return fetch.Request{
|
||||
URL: job.sourceURL,
|
||||
Directory: job.directory,
|
||||
FileName: job.requestedName,
|
||||
ReservedBytes: current.MinFreeBytes(),
|
||||
Connections: current.ConnectionsPerDownload,
|
||||
State: job.currentDownloadState(),
|
||||
Progress: job,
|
||||
Checkpoint: func(state fetch.State) {
|
||||
job.setDownloadState(&state)
|
||||
job.saveRecord()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) handleDownloadError(job *Job, runContext context.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(context.Cause(runContext), errPaused):
|
||||
job.setStatus(StatusPaused, "")
|
||||
case job.jobContext.Err() != nil:
|
||||
job.setStatus(StatusCancelled, "")
|
||||
return
|
||||
default:
|
||||
if errors.Is(err, fetch.ErrSourceChanged) || errors.Is(err, fetch.ErrResumeUnsupported) {
|
||||
job.setDownloadState(nil)
|
||||
}
|
||||
job.setStatus(StatusFailed, err.Error())
|
||||
}
|
||||
job.saveRecord()
|
||||
}
|
||||
Executable → Regular
+1
-30
@@ -14,35 +14,6 @@ const maxArchiveEntries = 100000
|
||||
|
||||
var errNotArchive = errors.New("selected file is not a supported archive")
|
||||
|
||||
func (manager *Manager) download(job *Job) {
|
||||
defer job.workers.Done()
|
||||
|
||||
if !manager.slots.acquire(job.jobContext, manager.concurrencyLimit) {
|
||||
return
|
||||
}
|
||||
defer manager.slots.release()
|
||||
|
||||
job.setStatus(StatusDownloading, "")
|
||||
filePath, err := manager.downloader.Download(job.jobContext, job.sourceURL, job.directory, manager.reservedBytes(), job)
|
||||
if err != nil {
|
||||
manager.finishWithError(job, StatusFailed, "", err)
|
||||
return
|
||||
}
|
||||
job.SetFileName(filepath.Base(filePath))
|
||||
|
||||
if job.autoExtract && extract.IsArchive(filePath) {
|
||||
job.setStatus(StatusExtracting, "")
|
||||
destination, err := manager.extractArchive(job, filePath)
|
||||
if err != nil {
|
||||
manager.finishWithError(job, StatusReady, "Extraction failed: ", err)
|
||||
return
|
||||
}
|
||||
job.SetFileName(filepath.Base(destination))
|
||||
}
|
||||
|
||||
job.setStatus(StatusReady, "")
|
||||
}
|
||||
|
||||
func (manager *Manager) Extract(id, relativePath string) error {
|
||||
job, err := manager.lookup(id)
|
||||
if err != nil {
|
||||
@@ -58,7 +29,7 @@ func (manager *Manager) Extract(id, relativePath string) error {
|
||||
return invalid(errNotArchive)
|
||||
}
|
||||
|
||||
if !job.begin(StatusReady, StatusExtracting) {
|
||||
if !job.begin(StatusExtracting, StatusReady) {
|
||||
return ErrJobBusy
|
||||
}
|
||||
go func() {
|
||||
@@ -0,0 +1,33 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"ddc/internal/history"
|
||||
)
|
||||
|
||||
func (manager *Manager) History() []history.Entry {
|
||||
return manager.history.List()
|
||||
}
|
||||
|
||||
func (manager *Manager) ClearHistory() error {
|
||||
return manager.history.Clear()
|
||||
}
|
||||
|
||||
func (manager *Manager) recordHistory(job *Job, destination string, movedNames []string) {
|
||||
if len(movedNames) == 0 {
|
||||
return
|
||||
}
|
||||
snapshot := job.snapshot()
|
||||
entry := history.Entry{
|
||||
FileName: snapshot.FileName,
|
||||
SourceURL: snapshot.SourceURL,
|
||||
Destination: destination,
|
||||
Items: movedNames,
|
||||
MovedAt: time.Now(),
|
||||
}
|
||||
if err := manager.history.Add(entry); err != nil {
|
||||
log.Printf("could not save history: %v", err)
|
||||
}
|
||||
}
|
||||
+42
-43
@@ -2,9 +2,12 @@ package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"ddc/internal/fetch"
|
||||
)
|
||||
|
||||
type Status string
|
||||
@@ -12,6 +15,7 @@ type Status string
|
||||
const (
|
||||
StatusQueued Status = "queued"
|
||||
StatusDownloading Status = "downloading"
|
||||
StatusPaused Status = "paused"
|
||||
StatusExtracting Status = "extracting"
|
||||
StatusReady Status = "ready"
|
||||
StatusMoving Status = "moving"
|
||||
@@ -21,56 +25,46 @@ const (
|
||||
)
|
||||
|
||||
type Snapshot struct {
|
||||
ID string `json:"id"`
|
||||
SourceURL string `json:"sourceUrl"`
|
||||
FileName string `json:"fileName"`
|
||||
Status Status `json:"status"`
|
||||
Message string `json:"message"`
|
||||
BytesDone int64 `json:"bytesDone"`
|
||||
BytesTotal int64 `json:"bytesTotal"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
SourceURL string `json:"sourceUrl"`
|
||||
FileName string `json:"fileName"`
|
||||
Status Status `json:"status"`
|
||||
Message string `json:"message"`
|
||||
BytesDone int64 `json:"bytesDone"`
|
||||
BytesTotal int64 `json:"bytesTotal"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
MoveTarget string `json:"moveTarget,omitempty"`
|
||||
QueuePosition int `json:"queuePosition,omitempty"`
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
id string
|
||||
sourceURL string
|
||||
displayURL string
|
||||
directory string
|
||||
autoExtract bool
|
||||
createdAt time.Time
|
||||
jobContext context.Context
|
||||
cancel context.CancelFunc
|
||||
workers sync.WaitGroup
|
||||
id string
|
||||
sourceURL string
|
||||
displayURL string
|
||||
directory string
|
||||
requestedName string
|
||||
autoExtract bool
|
||||
target *moveTarget
|
||||
createdAt time.Time
|
||||
jobContext context.Context
|
||||
cancel context.CancelFunc
|
||||
workers sync.WaitGroup
|
||||
|
||||
mutex sync.Mutex
|
||||
status Status
|
||||
message string
|
||||
fileName string
|
||||
deleted bool
|
||||
mutex sync.Mutex
|
||||
status Status
|
||||
message string
|
||||
fileName string
|
||||
deleted bool
|
||||
downloaded bool
|
||||
downloadState *fetch.State
|
||||
cancelRun context.CancelCauseFunc
|
||||
|
||||
recordMutex sync.Mutex
|
||||
|
||||
bytesDone atomic.Int64
|
||||
bytesTotal atomic.Int64
|
||||
}
|
||||
|
||||
func (job *Job) SetTotal(total int64) {
|
||||
job.bytesTotal.Store(total)
|
||||
}
|
||||
|
||||
func (job *Job) Add(delta int64) {
|
||||
job.bytesDone.Add(delta)
|
||||
}
|
||||
|
||||
func (job *Job) SetFileName(name string) {
|
||||
job.mutex.Lock()
|
||||
defer job.mutex.Unlock()
|
||||
job.fileName = name
|
||||
}
|
||||
|
||||
func (job *Job) resetProgress() {
|
||||
job.bytesDone.Store(0)
|
||||
job.bytesTotal.Store(0)
|
||||
}
|
||||
|
||||
func (job *Job) setStatus(status Status, message string) {
|
||||
job.mutex.Lock()
|
||||
defer job.mutex.Unlock()
|
||||
@@ -87,10 +81,10 @@ func (job *Job) currentStatus() Status {
|
||||
return job.status
|
||||
}
|
||||
|
||||
func (job *Job) begin(from, to Status) bool {
|
||||
func (job *Job) begin(to Status, allowedFrom ...Status) bool {
|
||||
job.mutex.Lock()
|
||||
defer job.mutex.Unlock()
|
||||
if job.deleted || job.status != from {
|
||||
if job.deleted || !slices.Contains(allowedFrom, job.status) {
|
||||
return false
|
||||
}
|
||||
job.status = to
|
||||
@@ -113,6 +107,10 @@ func (job *Job) markDeleted() bool {
|
||||
func (job *Job) snapshot() Snapshot {
|
||||
job.mutex.Lock()
|
||||
defer job.mutex.Unlock()
|
||||
moveTargetPath := ""
|
||||
if job.target != nil {
|
||||
moveTargetPath = job.target.displayPath()
|
||||
}
|
||||
return Snapshot{
|
||||
ID: job.id,
|
||||
SourceURL: job.displayURL,
|
||||
@@ -122,5 +120,6 @@ func (job *Job) snapshot() Snapshot {
|
||||
BytesDone: job.bytesDone.Load(),
|
||||
BytesTotal: job.bytesTotal.Load(),
|
||||
CreatedAt: job.createdAt,
|
||||
MoveTarget: moveTargetPath,
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"ddc/internal/fetch"
|
||||
"ddc/internal/files"
|
||||
"ddc/internal/guard"
|
||||
"ddc/internal/history"
|
||||
"ddc/internal/libraries"
|
||||
"ddc/internal/preferences"
|
||||
)
|
||||
@@ -31,6 +33,7 @@ var (
|
||||
type Manager struct {
|
||||
settings config.Settings
|
||||
preferences *preferences.Store
|
||||
history *history.Store
|
||||
policy *guard.Policy
|
||||
downloader *fetch.Downloader
|
||||
rootContext context.Context
|
||||
@@ -40,12 +43,13 @@ type Manager struct {
|
||||
jobs map[string]*Job
|
||||
}
|
||||
|
||||
func NewManager(settings config.Settings, store *preferences.Store) *Manager {
|
||||
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,
|
||||
@@ -63,6 +67,11 @@ func (manager *Manager) Preferences() preferences.Preferences {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -82,11 +91,27 @@ func (manager *Manager) AvailableBytes() (int64, error) {
|
||||
return diskspace.Available(manager.settings.DownloadDir)
|
||||
}
|
||||
|
||||
func (manager *Manager) Create(rawURL string, autoExtract bool) (Snapshot, error) {
|
||||
target, err := manager.policy.ValidateURL(rawURL)
|
||||
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
|
||||
}
|
||||
@@ -103,13 +128,16 @@ func (manager *Manager) Create(rawURL string, autoExtract bool) (Snapshot, error
|
||||
job := manager.newJob(id, directory, time.Now())
|
||||
job.sourceURL = target.String()
|
||||
job.displayURL = target.Redacted()
|
||||
job.autoExtract = autoExtract
|
||||
job.requestedName = requestedName
|
||||
job.autoExtract = request.AutoExtract
|
||||
job.target = moveTarget
|
||||
job.status = StatusQueued
|
||||
job.fileName = initialFileName(target.Path, target.Host)
|
||||
job.fileName = initialFileName(requestedName, target.Path, target.Host)
|
||||
job.workers.Add(1)
|
||||
|
||||
manager.store(job)
|
||||
go manager.download(job)
|
||||
job.saveRecord()
|
||||
manager.launchDownload(job)
|
||||
return job.snapshot(), nil
|
||||
}
|
||||
|
||||
@@ -117,7 +145,11 @@ func (manager *Manager) List() []Snapshot {
|
||||
manager.mutex.RLock()
|
||||
snapshots := make([]Snapshot, 0, len(manager.jobs))
|
||||
for _, job := range manager.jobs {
|
||||
snapshots = append(snapshots, job.snapshot())
|
||||
snapshot := job.snapshot()
|
||||
if snapshot.Status == StatusQueued {
|
||||
snapshot.QueuePosition = manager.slots.position(job.id)
|
||||
}
|
||||
snapshots = append(snapshots, snapshot)
|
||||
}
|
||||
manager.mutex.RUnlock()
|
||||
|
||||
@@ -157,8 +189,26 @@ func (manager *Manager) Delete(id string) error {
|
||||
return os.RemoveAll(job.directory)
|
||||
}
|
||||
|
||||
func (manager *Manager) CancelAll() {
|
||||
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():
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) newJob(id, directory string, createdAt time.Time) *Job {
|
||||
@@ -196,7 +246,10 @@ func newJobID() (string, error) {
|
||||
return hex.EncodeToString(buffer), nil
|
||||
}
|
||||
|
||||
func initialFileName(urlPath, host string) string {
|
||||
func initialFileName(requestedName, urlPath, host string) string {
|
||||
if requestedName != "" {
|
||||
return requestedName
|
||||
}
|
||||
if name := fetch.SanitizeFileName(path.Base(urlPath)); name != "" {
|
||||
return name
|
||||
}
|
||||
|
||||
+15
-11
@@ -7,7 +7,6 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"ddc/internal/files"
|
||||
"ddc/internal/libraries"
|
||||
)
|
||||
|
||||
type MoveRequest struct {
|
||||
@@ -29,19 +28,18 @@ func (manager *Manager) Move(id string, request MoveRequest) error {
|
||||
return invalid(errors.New("select at least one file or folder"))
|
||||
}
|
||||
|
||||
library, err := libraries.Resolve(manager.settings.LibraryRoots, request.Destination)
|
||||
target, err := manager.resolveTarget(request.Destination, request.Subfolder)
|
||||
if err != nil {
|
||||
return invalid(err)
|
||||
return err
|
||||
}
|
||||
subfolder, err := files.SanitizeSubfolder(request.Subfolder)
|
||||
if err != nil {
|
||||
return invalid(err)
|
||||
if target == nil {
|
||||
return invalid(errors.New("choose a destination"))
|
||||
}
|
||||
sources, err := resolveSources(job.directory, request.Paths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
destination, err := files.EnsureSubdirectory(library.Path, subfolder)
|
||||
destination, err := files.EnsureSubdirectory(target.destination, target.subfolder)
|
||||
if err != nil {
|
||||
return invalid(err)
|
||||
}
|
||||
@@ -49,12 +47,12 @@ func (manager *Manager) Move(id string, request MoveRequest) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if !job.begin(StatusReady, StatusMoving) {
|
||||
if !job.begin(StatusMoving, StatusReady) {
|
||||
return ErrJobBusy
|
||||
}
|
||||
go func() {
|
||||
defer job.workers.Done()
|
||||
manager.moveFiles(job, library, sources, destination, request.Cleanup)
|
||||
manager.moveFiles(job, sources, destination, request.Cleanup)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
@@ -69,6 +67,9 @@ func resolveSources(root string, relativePaths []string) ([]string, error) {
|
||||
return nil, invalid(err)
|
||||
}
|
||||
name := filepath.Base(source)
|
||||
if files.IsInternal(name) {
|
||||
return nil, invalid(files.ErrUnsafePath)
|
||||
}
|
||||
if seenNames[name] {
|
||||
return nil, invalid(fmt.Errorf("more than one selected item is named %q", name))
|
||||
}
|
||||
@@ -96,7 +97,7 @@ func checkTargets(sources []string, destination string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *Manager) moveFiles(job *Job, library libraries.Destination, sources []string, destination string, cleanup bool) {
|
||||
func (manager *Manager) moveFiles(job *Job, sources []string, destination string, cleanup bool) {
|
||||
job.resetProgress()
|
||||
var totalBytes int64
|
||||
for _, source := range sources {
|
||||
@@ -106,14 +107,17 @@ func (manager *Manager) moveFiles(job *Job, library libraries.Destination, sourc
|
||||
}
|
||||
job.SetTotal(totalBytes)
|
||||
|
||||
movedNames := make([]string, 0, len(sources))
|
||||
defer func() { manager.recordHistory(job, destination, movedNames) }()
|
||||
for _, source := range sources {
|
||||
if err := files.MoveInto(source, destination, manager.reservedBytes(), job.Add); err != nil {
|
||||
job.setStatus(StatusReady, "Move failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
movedNames = append(movedNames, filepath.Base(source))
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("Moved %d item(s) to %s", len(sources), library.Path)
|
||||
message := fmt.Sprintf("Moved %d item(s) to %s", len(sources), destination)
|
||||
if !cleanup {
|
||||
job.setStatus(StatusReady, message)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"ddc/internal/fetch"
|
||||
)
|
||||
|
||||
var errPaused = errors.New("download paused")
|
||||
|
||||
func (job *Job) SetTotal(total int64) {
|
||||
job.bytesTotal.Store(total)
|
||||
}
|
||||
|
||||
func (job *Job) SetDone(done int64) {
|
||||
job.bytesDone.Store(done)
|
||||
}
|
||||
|
||||
func (job *Job) Add(delta int64) {
|
||||
job.bytesDone.Add(delta)
|
||||
}
|
||||
|
||||
func (job *Job) SetFileName(name string) {
|
||||
job.mutex.Lock()
|
||||
defer job.mutex.Unlock()
|
||||
job.fileName = name
|
||||
}
|
||||
|
||||
func (job *Job) SetNotice(text string) {
|
||||
job.mutex.Lock()
|
||||
defer job.mutex.Unlock()
|
||||
if !job.deleted && job.status == StatusDownloading {
|
||||
job.message = text
|
||||
}
|
||||
}
|
||||
|
||||
func (job *Job) resetProgress() {
|
||||
job.bytesDone.Store(0)
|
||||
job.bytesTotal.Store(0)
|
||||
}
|
||||
|
||||
func (job *Job) startRun() (context.Context, context.CancelCauseFunc) {
|
||||
runContext, cancelRun := context.WithCancelCause(job.jobContext)
|
||||
job.mutex.Lock()
|
||||
defer job.mutex.Unlock()
|
||||
job.cancelRun = cancelRun
|
||||
return runContext, cancelRun
|
||||
}
|
||||
|
||||
func (job *Job) pause() bool {
|
||||
job.mutex.Lock()
|
||||
defer job.mutex.Unlock()
|
||||
if job.cancelRun == nil || (job.status != StatusQueued && job.status != StatusDownloading) {
|
||||
return false
|
||||
}
|
||||
job.cancelRun(errPaused)
|
||||
return true
|
||||
}
|
||||
|
||||
func (job *Job) currentDownloadState() *fetch.State {
|
||||
job.mutex.Lock()
|
||||
defer job.mutex.Unlock()
|
||||
return job.downloadState
|
||||
}
|
||||
|
||||
func (job *Job) setDownloadState(state *fetch.State) {
|
||||
job.mutex.Lock()
|
||||
defer job.mutex.Unlock()
|
||||
job.downloadState = state
|
||||
}
|
||||
|
||||
func (job *Job) markDownloaded(fileName string) {
|
||||
job.mutex.Lock()
|
||||
defer job.mutex.Unlock()
|
||||
job.downloaded = true
|
||||
job.downloadState = nil
|
||||
job.fileName = fileName
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"ddc/internal/fetch"
|
||||
"ddc/internal/files"
|
||||
)
|
||||
|
||||
const (
|
||||
phaseDownloading = "downloading"
|
||||
phasePaused = "paused"
|
||||
phaseFailed = "failed"
|
||||
phaseDone = "done"
|
||||
)
|
||||
|
||||
type jobRecord struct {
|
||||
SourceURL string `json:"sourceUrl"`
|
||||
DisplayURL string `json:"displayUrl"`
|
||||
RequestedName string `json:"requestedName,omitempty"`
|
||||
FileName string `json:"fileName"`
|
||||
AutoExtract bool `json:"autoExtract"`
|
||||
Destination string `json:"destination,omitempty"`
|
||||
Subfolder string `json:"subfolder,omitempty"`
|
||||
Phase string `json:"phase"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Download *fetch.State `json:"download,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (job *Job) record() jobRecord {
|
||||
job.mutex.Lock()
|
||||
defer job.mutex.Unlock()
|
||||
record := jobRecord{
|
||||
SourceURL: job.sourceURL,
|
||||
DisplayURL: job.displayURL,
|
||||
RequestedName: job.requestedName,
|
||||
FileName: job.fileName,
|
||||
AutoExtract: job.autoExtract,
|
||||
Phase: phaseFor(job.status, job.downloaded),
|
||||
Message: job.message,
|
||||
Download: job.downloadState,
|
||||
CreatedAt: job.createdAt,
|
||||
}
|
||||
if job.target != nil {
|
||||
record.Destination = job.target.destination
|
||||
record.Subfolder = job.target.subfolder
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func phaseFor(status Status, downloaded bool) string {
|
||||
switch {
|
||||
case downloaded:
|
||||
return phaseDone
|
||||
case status == StatusPaused:
|
||||
return phasePaused
|
||||
case status == StatusFailed:
|
||||
return phaseFailed
|
||||
default:
|
||||
return phaseDownloading
|
||||
}
|
||||
}
|
||||
|
||||
func (job *Job) saveRecord() {
|
||||
data, err := json.MarshalIndent(job.record(), "", " ")
|
||||
if err != nil {
|
||||
log.Printf("could not encode download %s: %v", job.id, err)
|
||||
return
|
||||
}
|
||||
job.recordMutex.Lock()
|
||||
defer job.recordMutex.Unlock()
|
||||
if err := files.WriteAtomically(filepath.Join(job.directory, files.JobFileName), data); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("could not save download %s: %v", job.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func readRecord(directory string) (jobRecord, bool) {
|
||||
data, err := os.ReadFile(filepath.Join(directory, files.JobFileName))
|
||||
if err != nil {
|
||||
return jobRecord{}, false
|
||||
}
|
||||
var record jobRecord
|
||||
if err := json.Unmarshal(data, &record); err != nil {
|
||||
return jobRecord{}, false
|
||||
}
|
||||
return record, true
|
||||
}
|
||||
|
||||
func (job *Job) applyRecord(record jobRecord) {
|
||||
job.sourceURL = record.SourceURL
|
||||
job.displayURL = record.DisplayURL
|
||||
job.requestedName = record.RequestedName
|
||||
job.fileName = record.FileName
|
||||
job.autoExtract = record.AutoExtract
|
||||
if !record.CreatedAt.IsZero() {
|
||||
job.createdAt = record.CreatedAt
|
||||
}
|
||||
if record.Destination != "" {
|
||||
job.target = &moveTarget{destination: record.Destination, subfolder: record.Subfolder}
|
||||
}
|
||||
}
|
||||
+53
-12
@@ -1,9 +1,11 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"ddc/internal/files"
|
||||
@@ -17,33 +19,72 @@ func (manager *Manager) RestoreExisting() error {
|
||||
return err
|
||||
}
|
||||
|
||||
var queuedJobs []*Job
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() || !jobIDPattern.MatchString(entry.Name()) {
|
||||
continue
|
||||
}
|
||||
directory := filepath.Join(manager.settings.DownloadDir, entry.Name())
|
||||
os.Remove(filepath.Join(directory, files.PartFileName))
|
||||
|
||||
createdAt := time.Now()
|
||||
if info, err := entry.Info(); err == nil {
|
||||
createdAt = info.ModTime()
|
||||
}
|
||||
|
||||
job := manager.newJob(entry.Name(), directory, createdAt)
|
||||
job.status = StatusReady
|
||||
job.fileName = firstEntryName(directory, entry.Name())
|
||||
job := manager.restoreJob(entry)
|
||||
manager.store(job)
|
||||
if job.status == StatusQueued {
|
||||
queuedJobs = append(queuedJobs, job)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(queuedJobs, func(first, second int) bool {
|
||||
return queuedJobs[first].createdAt.Before(queuedJobs[second].createdAt)
|
||||
})
|
||||
for _, job := range queuedJobs {
|
||||
job.workers.Add(1)
|
||||
manager.launchDownload(job)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *Manager) restoreJob(entry fs.DirEntry) *Job {
|
||||
directory := filepath.Join(manager.settings.DownloadDir, entry.Name())
|
||||
createdAt := time.Now()
|
||||
if info, err := entry.Info(); err == nil {
|
||||
createdAt = info.ModTime()
|
||||
}
|
||||
job := manager.newJob(entry.Name(), directory, createdAt)
|
||||
|
||||
record, found := readRecord(directory)
|
||||
if found {
|
||||
job.applyRecord(record)
|
||||
}
|
||||
if !found || record.Phase == phaseDone {
|
||||
os.Remove(filepath.Join(directory, files.PartFileName))
|
||||
job.status = StatusReady
|
||||
job.downloaded = true
|
||||
job.fileName = firstEntryName(directory, entry.Name())
|
||||
return job
|
||||
}
|
||||
|
||||
job.downloadState = record.Download
|
||||
if record.Download != nil {
|
||||
job.bytesDone.Store(record.Download.DoneBytes())
|
||||
job.bytesTotal.Store(record.Download.TotalBytes)
|
||||
}
|
||||
switch record.Phase {
|
||||
case phasePaused:
|
||||
job.status = StatusPaused
|
||||
case phaseFailed:
|
||||
job.status = StatusFailed
|
||||
job.message = record.Message
|
||||
default:
|
||||
job.status = StatusQueued
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
||||
func firstEntryName(directory, fallback string) string {
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.Name() != files.PartFileName {
|
||||
if !files.IsInternal(entry.Name()) {
|
||||
return entry.Name()
|
||||
}
|
||||
}
|
||||
|
||||
+61
-6
@@ -2,38 +2,57 @@ package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const slotWaitInterval = 500 * time.Millisecond
|
||||
|
||||
const (
|
||||
queueFirst = "first"
|
||||
queueUp = "up"
|
||||
queueDown = "down"
|
||||
)
|
||||
|
||||
type slotLimiter struct {
|
||||
mutex sync.Mutex
|
||||
active int
|
||||
mutex sync.Mutex
|
||||
active int
|
||||
waiting []string
|
||||
}
|
||||
|
||||
func (limiter *slotLimiter) acquire(ctx context.Context, limit func() int) bool {
|
||||
func (limiter *slotLimiter) enqueue(id string) {
|
||||
limiter.mutex.Lock()
|
||||
defer limiter.mutex.Unlock()
|
||||
if !slices.Contains(limiter.waiting, id) {
|
||||
limiter.waiting = append(limiter.waiting, id)
|
||||
}
|
||||
}
|
||||
|
||||
func (limiter *slotLimiter) acquire(ctx context.Context, id string, limit func() int) bool {
|
||||
limiter.enqueue(id)
|
||||
ticker := time.NewTicker(slotWaitInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if limiter.tryAcquire(limit()) {
|
||||
if limiter.tryAcquire(id, limit()) {
|
||||
return true
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
limiter.remove(id)
|
||||
return false
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (limiter *slotLimiter) tryAcquire(limit int) bool {
|
||||
func (limiter *slotLimiter) tryAcquire(id string, limit int) bool {
|
||||
limiter.mutex.Lock()
|
||||
defer limiter.mutex.Unlock()
|
||||
if limiter.active >= limit {
|
||||
if limiter.active >= limit || len(limiter.waiting) == 0 || limiter.waiting[0] != id {
|
||||
return false
|
||||
}
|
||||
limiter.waiting = limiter.waiting[1:]
|
||||
limiter.active++
|
||||
return true
|
||||
}
|
||||
@@ -43,3 +62,39 @@ func (limiter *slotLimiter) release() {
|
||||
defer limiter.mutex.Unlock()
|
||||
limiter.active--
|
||||
}
|
||||
|
||||
func (limiter *slotLimiter) remove(id string) {
|
||||
limiter.mutex.Lock()
|
||||
defer limiter.mutex.Unlock()
|
||||
limiter.waiting = slices.DeleteFunc(limiter.waiting, func(waitingID string) bool {
|
||||
return waitingID == id
|
||||
})
|
||||
}
|
||||
|
||||
func (limiter *slotLimiter) position(id string) int {
|
||||
limiter.mutex.Lock()
|
||||
defer limiter.mutex.Unlock()
|
||||
return slices.Index(limiter.waiting, id) + 1
|
||||
}
|
||||
|
||||
func (limiter *slotLimiter) reorder(id, direction string) bool {
|
||||
limiter.mutex.Lock()
|
||||
defer limiter.mutex.Unlock()
|
||||
index := slices.Index(limiter.waiting, id)
|
||||
if index < 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
target := index
|
||||
switch direction {
|
||||
case queueFirst:
|
||||
target = 0
|
||||
case queueUp:
|
||||
target = max(index-1, 0)
|
||||
case queueDown:
|
||||
target = min(index+1, len(limiter.waiting)-1)
|
||||
}
|
||||
limiter.waiting = slices.Delete(limiter.waiting, index, index+1)
|
||||
limiter.waiting = slices.Insert(limiter.waiting, target, id)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"ddc/internal/files"
|
||||
"ddc/internal/libraries"
|
||||
"ddc/internal/preferences"
|
||||
)
|
||||
|
||||
type moveTarget struct {
|
||||
destination string
|
||||
subfolder string
|
||||
}
|
||||
|
||||
func (target *moveTarget) displayPath() string {
|
||||
return filepath.Join(target.destination, target.subfolder)
|
||||
}
|
||||
|
||||
func (manager *Manager) resolveTarget(destination, subfolder string) (*moveTarget, error) {
|
||||
if strings.TrimSpace(destination) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
libraryPath, extraFolder, err := manager.splitDownloadPath(destination)
|
||||
if err != nil {
|
||||
return nil, invalid(err)
|
||||
}
|
||||
cleanSubfolder, err := files.SanitizeSubfolder(subfolder)
|
||||
if err != nil {
|
||||
return nil, invalid(err)
|
||||
}
|
||||
combinedSubfolder, err := files.SanitizeSubfolder(extraFolder + "/" + cleanSubfolder)
|
||||
if err != nil {
|
||||
return nil, invalid(err)
|
||||
}
|
||||
return &moveTarget{destination: libraryPath, subfolder: combinedSubfolder}, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) splitDownloadPath(fullPath string) (string, string, error) {
|
||||
cleaned := filepath.Clean(strings.TrimSpace(fullPath))
|
||||
for _, root := range manager.settings.LibraryRoots {
|
||||
if !files.IsWithin(root, cleaned) {
|
||||
continue
|
||||
}
|
||||
relative, err := filepath.Rel(root, cleaned)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
libraryName, extraFolder, _ := strings.Cut(filepath.ToSlash(relative), "/")
|
||||
library, err := libraries.Resolve(manager.settings.LibraryRoots, filepath.Join(root, libraryName))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return library.Path, extraFolder, nil
|
||||
}
|
||||
return "", "", libraries.ErrUnknownDestination
|
||||
}
|
||||
|
||||
func (manager *Manager) normalizeDownloadPaths(downloadPaths []string) ([]string, error) {
|
||||
normalized := make([]string, 0, len(downloadPaths))
|
||||
seenPaths := make(map[string]bool)
|
||||
for _, downloadPath := range downloadPaths {
|
||||
target, err := manager.resolveTarget(downloadPath, "")
|
||||
if err != nil || target == nil {
|
||||
return nil, fmt.Errorf("%w: %q is not inside a destination folder", preferences.ErrInvalid, downloadPath)
|
||||
}
|
||||
fullPath := target.displayPath()
|
||||
if !seenPaths[fullPath] {
|
||||
seenPaths[fullPath] = true
|
||||
normalized = append(normalized, fullPath)
|
||||
}
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) moveToTarget(job *Job) {
|
||||
if job.target == nil {
|
||||
return
|
||||
}
|
||||
names, err := topLevelNames(job.directory)
|
||||
if err == nil {
|
||||
err = manager.Move(job.id, MoveRequest{
|
||||
Paths: names,
|
||||
Destination: job.target.destination,
|
||||
Subfolder: job.target.subfolder,
|
||||
Cleanup: true,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
job.setStatus(StatusReady, "Move failed: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func topLevelNames(directory string) ([]string, error) {
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if !files.IsInternal(entry.Name()) {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
Reference in new issue
Block a user