Add resumable downloads, download paths, queue controls and history

This commit is contained in:
SirBlob committed 2026-11-01 13:36:29 -05:00
1 parent 434f6441da
commit 6027cfabb9
43 files changed
+2138 -291

No files matched your search

+122
View File
@@ -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()
}
+1 -30
View File
@@ -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() {
+33
View File
@@ -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
View File
@@ -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,
}
}
+62 -9
View File
@@ -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
View File
@@ -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
+79
View File
@@ -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
}
+106
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+109
View File
@@ -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
}