Files
gopeed/pkg/download/downloader.go
T

2180 lines
57 KiB
Go

package download
import (
"errors"
"fmt"
"math"
gohttp "net/http"
"net/url"
"os"
"path/filepath"
"runtime/debug"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
internalblob "github.com/GopeedLab/gopeed/internal/blob"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/logger"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
"github.com/GopeedLab/gopeed/pkg/util"
gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/rs/zerolog"
"github.com/rs/zerolog/pkgerrors"
)
const (
// task info bucket
bucketTask = "task"
// task download data bucket
bucketSave = "save"
// protocol-level shared client state bucket
bucketProtocolState = "protocol_state"
// downloader config bucket
bucketConfig = "config"
// downloader extension bucket
bucketExtension = "extension"
// downloader extension storage bucket
bucketExtensionStorage = "extension_storage"
)
var (
ErrTaskNotFound = errors.New("task not found")
ErrUnSupportedProtocol = errors.New("unsupported protocol")
)
type Listener func(event *Event)
// ExtractStatus represents the current status of archive extraction
type ExtractStatus string
const (
// ExtractStatusNone indicates extraction has not started
ExtractStatusNone ExtractStatus = ""
// ExtractStatusQueued indicates extraction is waiting in the queue
ExtractStatusQueued ExtractStatus = "queued"
// ExtractStatusWaitingParts indicates waiting for other multi-part archive parts to complete
ExtractStatusWaitingParts ExtractStatus = "waitingParts"
// ExtractStatusExtracting indicates extraction is in progress
ExtractStatusExtracting ExtractStatus = "extracting"
// ExtractStatusDone indicates extraction completed successfully
ExtractStatusDone ExtractStatus = "done"
// ExtractStatusError indicates extraction failed
ExtractStatusError ExtractStatus = "error"
)
type Progress struct {
// Total download time(ns)
Used int64 `json:"used"`
// Download speed(bytes/s)
Speed int64 `json:"speed"`
// Downloaded size(bytes)
Downloaded int64 `json:"downloaded"`
// Uploaded speed(bytes/s)
UploadSpeed int64 `json:"uploadSpeed"`
// Uploaded size(bytes)
Uploaded int64 `json:"uploaded"`
// ExtractStatus indicates the current status of archive extraction
ExtractStatus ExtractStatus `json:"extractStatus"`
// ExtractProgress is the percentage of extraction completed (0-100)
ExtractProgress int `json:"extractProgress"`
// MultiPartBaseName is set for multi-part archives to group related parts
MultiPartBaseName string `json:"multiPartBaseName,omitempty"`
// MultiPartNumber is the part number for multi-part archives (1-indexed)
MultiPartNumber int `json:"multiPartNumber,omitempty"`
// MultiPartIsFirst indicates if this is the first part of a multi-part archive
MultiPartIsFirst bool `json:"multiPartIsFirst,omitempty"`
}
type Downloader struct {
Logger *logger.Logger
ExtensionLogger *logger.Logger
cfg *DownloaderConfig
fetcherCache map[string]fetcher.Fetcher
storage Storage
tasks []*Task
waitTasks []*Task
watchedTasks sync.Map
listener Listener
lock *sync.Mutex
fetcherMapLock *sync.RWMutex
checkDuplicateLock *sync.Mutex
closed atomic.Bool
// claimedExtractions tracks which multi-part archives have been claimed for extraction
// Key: fullBaseName (e.g., "/path/archive.7z"), Value: taskID that claimed it
claimedExtractions sync.Map
extensions []*Extension
blob *internalblob.Registry
}
func NewDownloader(cfg *DownloaderConfig) *Downloader {
if cfg == nil {
cfg = &DownloaderConfig{}
}
cfg.Init()
d := &Downloader{
cfg: cfg,
fetcherCache: make(map[string]fetcher.Fetcher),
waitTasks: make([]*Task, 0),
storage: cfg.Storage,
lock: &sync.Mutex{},
fetcherMapLock: &sync.RWMutex{},
checkDuplicateLock: &sync.Mutex{},
extensions: make([]*Extension, 0),
}
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
d.Logger = logger.NewLogger(cfg.ProductionMode, filepath.Join(cfg.StorageDir, "logs", "core.log"))
d.ExtensionLogger = logger.NewLogger(cfg.ProductionMode, filepath.Join(cfg.StorageDir, "logs", "extension.log"))
if cfg.ProductionMode {
logPanic(filepath.Join(cfg.StorageDir, "logs"))
}
return d
}
func (d *Downloader) Setup() error {
d.blob = internalblob.NewRegistry("")
// setup storage
if err := d.storage.Setup([]string{bucketTask, bucketSave, bucketProtocolState, bucketConfig, bucketExtension, bucketExtensionStorage}); err != nil {
return err
}
// load config from storage
var cfg base.DownloaderStoreConfig
exist, err := d.storage.Get(bucketConfig, "config", &cfg)
if err != nil {
return err
}
if exist {
d.cfg.DownloaderStoreConfig = &cfg
} else {
d.cfg.DownloaderStoreConfig = &base.DownloaderStoreConfig{
FirstLoad: true,
}
}
// init default config
d.cfg.DownloaderStoreConfig.Init()
// init protocol config, if not exist, use default config
for _, fm := range d.cfg.FetchManagers {
protocol := fm.Name()
if _, ok := d.cfg.DownloaderStoreConfig.ProtocolConfig[protocol]; !ok {
d.cfg.DownloaderStoreConfig.ProtocolConfig[protocol] = fm.DefaultConfig()
}
if sfm, ok := fm.(fetcher.StatefulFetcherManager); ok {
sfm.SetStateStore(&protocolStateStore{
storage: d.storage,
protocol: protocol,
})
}
}
// load tasks from storage
var tasks []*Task
if err = d.storage.List(bucketTask, &tasks); err != nil {
return err
}
if tasks == nil {
tasks = make([]*Task, 0)
} else {
for i := len(tasks) - 1; i >= 0; i-- {
task := tasks[i]
// Remove broken tasks
if task.Meta == nil {
tasks = append(tasks[:i], tasks[i+1:]...)
continue
}
d.assignFetcherManager(task)
initTask(task)
if task.Status != base.DownloadStatusDone && task.Status != base.DownloadStatusError {
task.Status = base.DownloadStatusPause
}
}
}
d.tasks = tasks
// sort by create time
sort.Slice(d.tasks, func(i, j int) bool {
return d.tasks[i].CreatedAt.Before(d.tasks[j].CreatedAt)
})
// load extensions from storage
var extensions []*Extension
if err = d.storage.List(bucketExtension, &extensions); err != nil {
return err
}
if extensions == nil {
extensions = make([]*Extension, 0)
}
d.extensions = extensions
// Auto-cleanup non-existing tasks on startup
d.cleanupNonExistingTasks()
// handle upload
go func() {
for _, task := range d.tasks {
if task.Status == base.DownloadStatusDone && task.Uploading {
if err := d.restoreTask(task); err != nil {
d.Logger.Error().Stack().Err(err).Msgf("task upload restore fetcher failed, task id: %s", task.ID)
}
if uploader, ok := task.fetcher.(fetcher.Uploader); ok {
if err := uploader.Upload(); err != nil {
d.Logger.Error().Stack().Err(err).Msgf("task upload failed, task id: %s", task.ID)
}
}
}
}
}()
// calculate download speed every tick
go func() {
for !d.closed.Load() {
if len(d.tasks) > 0 {
for _, task := range d.tasks {
func() {
// Do not acquire d.lock (via GetTask) while holding
// statusLock; scheduling uses the opposite lock order.
if d.GetTask(task.ID) == nil {
return
}
task.statusLock.Lock()
if task.Status != base.DownloadStatusRunning && !task.Uploading {
task.statusLock.Unlock()
return
}
if task.fetcher == nil {
task.statusLock.Unlock()
return
}
current := task.fetcher.Progress().TotalDownloaded()
tick := float64(d.cfg.RefreshInterval) / 1000
downloadDataChanged := false
if task.Status == base.DownloadStatusRunning {
downloadDataChanged = current != task.Progress.Downloaded
task.Progress.Used = task.timer.Used()
task.Progress.Speed = task.updateSpeed(current-task.Progress.Downloaded, tick)
task.Progress.Downloaded = current
}
uploadDataChanged := false
if task.Uploading {
uploader := task.fetcher.(fetcher.Uploader)
currentUploaded := uploader.UploadedBytes()
uploadDataChanged = currentUploaded != task.Progress.Uploaded
task.Progress.UploadSpeed = task.updateUploadSpeed(currentUploaded-task.Progress.Uploaded, tick)
task.Progress.Uploaded = currentUploaded
}
task.statusLock.Unlock()
// Listener callbacks may Pause/Continue and acquire statusLock.
d.emit(EventKeyProgress, task)
// store fetcher progress when download/upload data changed
if !downloadDataChanged && !uploadDataChanged {
return
}
d.saveTask(task)
}()
}
}
time.Sleep(time.Millisecond * time.Duration(d.cfg.RefreshInterval))
}
}()
return nil
}
// cleanupNonExistingTasks checks for tasks whose files are missing on disk
// and removes them if the AutoCleanMissingFiles config is enabled.
func (d *Downloader) cleanupNonExistingTasks() {
cfg, err := d.GetConfig()
if err != nil {
return
}
// If the feature is disabled, do nothing
if !cfg.AutoDeleteMissingFileTasks {
return
}
var tasksToDelete []string
for _, task := range d.tasks {
if task.Meta == nil || task.Meta.Res == nil {
continue
}
var targetPath string
// Determine if it is a single file or a directory (multi-file torrent)
if task.Meta.Res.Name != "" {
targetPath = task.Meta.FolderPath()
} else {
targetPath = task.Meta.SingleFilepath()
}
// Skip if path is empty
if targetPath == "" {
continue
}
// Check if file/folder exists
if _, err := os.Stat(targetPath); os.IsNotExist(err) {
d.Logger.Info().Msgf("Auto-cleanup: task %s file not found at %s, removing from list", task.ID, targetPath)
tasksToDelete = append(tasksToDelete, task.ID)
}
}
if len(tasksToDelete) > 0 {
d.Delete(&TaskFilter{IDs: tasksToDelete}, false)
}
}
func (d *Downloader) parseFm(url string) (fetcher.FetcherManager, error) {
for _, fm := range d.cfg.FetchManagers {
for _, filter := range fm.Filters() {
if filter.Match(url) {
return fm, nil
}
}
}
return nil, ErrUnSupportedProtocol
}
func (d *Downloader) setupFetcher(fm fetcher.FetcherManager, fetcher fetcher.Fetcher) {
ctl := controller.NewController()
ctl.GetConfig = func(v any) {
d.getProtocolConfig(fm.Name(), v)
}
// Get proxy config, task request proxy config has higher priority, then use global proxy config
ctl.GetProxy = func(requestProxy *base.RequestProxy) func(*gohttp.Request) (*url.URL, error) {
// Registry-backed blob URLs are loopback capability URLs and must never be
// sent through a configured proxy. Only exact, currently registered URLs
// take this path; everything else keeps normal HTTP proxy behavior.
if d.blob != nil && fetcher != nil && fetcher.Meta() != nil && fetcher.Meta().Req != nil && d.blob.IsURL(fetcher.Meta().Req.URL) {
return nil
}
if requestProxy == nil {
return d.cfg.Proxy.ToHandler()
}
switch requestProxy.Mode {
case base.RequestProxyModeNone:
return nil
case base.RequestProxyModeCustom:
return requestProxy.ToHandler()
default:
return d.cfg.Proxy.ToHandler()
}
}
fetcher.Setup(ctl)
}
func (d *Downloader) saveTask(task *Task) error {
data, err := task.fetcherManager.Store(task.fetcher)
if err != nil {
d.Logger.Error().Stack().Err(err).Msgf("serialize fetcher failed: %s", task.ID)
return err
}
if data != nil {
if err := d.storage.Put(bucketSave, task.ID, data); err != nil {
d.Logger.Error().Stack().Err(err).Msgf("persist fetcher failed: %s", task.ID)
return err
}
} else {
if err := d.storage.Delete(bucketSave, task.ID); err != nil {
d.Logger.Error().Stack().Err(err).Msgf("clear fetcher state failed: %s", task.ID)
return err
}
}
if err := d.storage.Put(bucketTask, task.ID, task); err != nil {
d.Logger.Error().Stack().Err(err).Msgf("persist task failed: %s", task.ID)
return err
}
return nil
}
func ensureRequestRawURL(req *base.Request) {
if req == nil {
return
}
if req.RawURL == "" {
req.RawURL = req.URL
}
}
func ensureResourceRequestRawURLs(parentReq *base.Request, res *base.Resource) {
if res == nil {
return
}
for _, file := range res.Files {
if file == nil || file.Req == nil {
continue
}
ensureRequestRawURL(file.Req)
if parentReq != nil && parentReq.RawURL != "" && file.Req.RawURL == file.Req.URL {
file.Req.RawURL = parentReq.RawURL
}
}
}
func (d *Downloader) Resolve(req *base.Request, opts *base.Options) (rr *ResolveResult, err error) {
if req == nil {
return nil, errors.New("request cannot be nil")
}
rrId, err := gonanoid.New()
if err != nil {
return
}
ensureRequestRawURL(req)
res, err := d.triggerOnResolve(req)
if err != nil {
return
}
if res != nil && len(res.Files) > 0 {
rr = &ResolveResult{
Res: res,
}
return
}
fetcher, err := d.buildFetcher(req.URL)
if err != nil {
return
}
initOpt, err := d.initOptions(opts)
if err != nil {
return
}
err = fetcher.Resolve(req, initOpt)
if err != nil {
return
}
d.fetcherMapLock.Lock()
d.fetcherCache[rrId] = fetcher
d.fetcherMapLock.Unlock()
rr = &ResolveResult{
ID: rrId,
Res: fetcher.Meta().Res,
}
return
}
func (d *Downloader) notifyRunning() {
go func() {
d.lock.Lock()
defer d.lock.Unlock()
remainRunningCount := d.remainRunningCount()
if remainRunningCount == 0 {
return
}
if len(d.waitTasks) > 0 {
wt := d.waitTasks[0]
d.waitTasks = d.waitTasks[1:]
d.doStart(wt)
}
}()
}
func (d *Downloader) remainRunningCount() int {
runningCount := 0
for _, t := range d.tasks {
if t.Status == base.DownloadStatusRunning {
runningCount++
}
}
return d.cfg.MaxRunning - runningCount
}
func (d *Downloader) CreateDirect(req *base.Request, opts *base.Options) (taskId string, err error) {
ensureRequestRawURL(req)
var fetcher fetcher.Fetcher
fetcher, err = d.buildFetcher(req.URL)
if err != nil {
return
}
fetcher.Meta().Req = req
initOpt, err := d.initOptions(opts)
if err != nil {
return
}
return d.doCreate(fetcher, initOpt)
}
func (d *Downloader) CreateDirectBatch(req *base.CreateTaskBatch) (taskId []string, err error) {
taskIds := make([]string, 0)
for _, ir := range req.Reqs {
opts := ir.Opts
if opts == nil {
opts = req.Opts
}
taskId, err := d.CreateDirect(ir.Req, opts.Clone())
if err != nil {
return nil, err
}
taskIds = append(taskIds, taskId)
}
return taskIds, nil
}
func (d *Downloader) Create(rrId string) (taskId string, err error) {
d.fetcherMapLock.RLock()
fetcher, ok := d.fetcherCache[rrId]
d.fetcherMapLock.RUnlock()
if !ok {
return "", errors.New("invalid resource id")
}
defer func() {
d.fetcherMapLock.Lock()
delete(d.fetcherCache, rrId)
d.fetcherMapLock.Unlock()
}()
return d.doCreate(fetcher, nil)
}
// Patch modifies task-specific data based on the protocol.
// For HTTP protocol, it can modify Request info.
// For BT protocol, it can modify SelectFiles.
func (d *Downloader) Patch(id string, req *base.Request, opts *base.Options) error {
task := d.GetTask(id)
if task == nil {
return ErrTaskNotFound
}
if err := func() error {
task.lock.Lock()
defer task.lock.Unlock()
// Restore fetcher if not loaded
if task.fetcher == nil {
err := func() error {
task.statusLock.Lock()
defer task.statusLock.Unlock()
return d.restoreFetcher(task)
}()
if err != nil {
return err
}
}
// Call the fetcher's Patch method
if err := task.fetcher.Patch(req, opts); err != nil {
return err
}
// Update task meta from fetcher and transfer any live Blob lease.
task.Meta = task.fetcher.Meta()
if err := d.syncBlobTaskLease(task); err != nil {
return err
}
return d.saveTask(task)
}(); err != nil {
return err
}
// Emit outside task.lock so listeners may safely call back into Patch/Pause.
d.emit(EventKeyProgress, task)
return nil
}
func (d *Downloader) Pause(filter *TaskFilter) (err error) {
if filter == nil || filter.IsEmpty() {
return d.pauseAll()
}
filter.NotStatuses = []base.Status{base.DownloadStatusPause, base.DownloadStatusError, base.DownloadStatusDone}
pauseTasks := d.GetTasksByFilter(filter)
if len(pauseTasks) == 0 {
return ErrTaskNotFound
}
for _, task := range pauseTasks {
if err = d.doPause(task); err != nil {
return
}
}
d.notifyRunning()
return
}
func (d *Downloader) pauseAll() (err error) {
func() {
d.lock.Lock()
defer d.lock.Unlock()
// Clear wait tasks
d.waitTasks = d.waitTasks[:0]
}()
for _, task := range d.tasks {
if err = d.doPause(task); err != nil {
return
}
}
return
}
// Continue specific tasks, if continue tasks will exceed maxRunning, it needs pause some running tasks before that
func (d *Downloader) Continue(filter *TaskFilter) (err error) {
if filter == nil || filter.IsEmpty() {
return d.continueAll()
}
filter.NotStatuses = []base.Status{base.DownloadStatusRunning, base.DownloadStatusDone}
continueTasks := d.GetTasksByFilter(filter)
if len(continueTasks) == 0 {
return ErrTaskNotFound
}
realContinueTasks := make([]*Task, 0)
pausedTasks := make([]*Task, 0)
func() {
d.lock.Lock()
defer d.lock.Unlock()
continueCount := len(continueTasks)
remainRunningCount := d.remainRunningCount()
needRunningCount := int(math.Min(float64(d.cfg.MaxRunning), float64(continueCount)))
needPauseCount := needRunningCount - remainRunningCount
if needPauseCount > 0 {
pausedCount := 0
for _, task := range d.tasks {
if task.Status == base.DownloadStatusRunning {
var queued bool
queued, err = d.doPauseForScheduling(task)
if err != nil {
return
}
if queued {
d.waitTasks = append(d.waitTasks, task)
pausedTasks = append(pausedTasks, task)
pausedCount++
}
}
if pausedCount == needPauseCount {
break
}
}
}
for _, task := range continueTasks {
if len(realContinueTasks) < needRunningCount {
realContinueTasks = append(realContinueTasks, task)
} else {
task.Status = base.DownloadStatusWait
d.waitTasks = append(d.waitTasks, task)
}
}
}()
if err != nil {
return
}
// Scheduling pauses run while d.lock is held, but events must not: listeners
// are allowed to call back into Downloader methods that acquire d.lock.
for _, task := range pausedTasks {
d.emit(EventKeyPause, task)
}
for _, task := range realContinueTasks {
if err = d.doStart(task); err != nil {
return
}
}
return
}
// continueAll continue all tasks but does not affect tasks already running
func (d *Downloader) continueAll() (err error) {
continuedTasks := make([]*Task, 0)
func() {
d.lock.Lock()
defer d.lock.Unlock()
// calculate how many tasks can be continued, can't exceed maxRunning
remainCount := d.remainRunningCount()
for _, task := range d.tasks {
if task.Status != base.DownloadStatusRunning && task.Status != base.DownloadStatusDone {
if len(continuedTasks) < remainCount {
continuedTasks = append(continuedTasks, task)
} else {
task.Status = base.DownloadStatusWait
d.waitTasks = append(d.waitTasks, task)
}
}
}
}()
for _, task := range continuedTasks {
if err = d.doStart(task); err != nil {
return
}
}
return
}
func (d *Downloader) ContinueBatch(filter *TaskFilter) (err error) {
if filter == nil || filter.IsEmpty() {
return d.continueAll()
}
continueTasks := d.GetTasksByFilter(filter)
for _, task := range continueTasks {
if err = d.doStart(task); err != nil {
return
}
}
return
}
func (d *Downloader) Delete(filter *TaskFilter, force bool) (err error) {
if filter == nil || filter.IsEmpty() {
return d.deleteAll(force)
}
deleteTasks := d.GetTasksByFilter(filter)
if len(deleteTasks) == 0 {
return
}
deleteIds := make([]string, 0)
deleteTasksPtr := make([]*Task, 0)
for _, task := range deleteTasks {
deleteIds = append(deleteIds, task.ID)
deleteTasksPtr = append(deleteTasksPtr, task)
}
func() {
d.lock.Lock()
defer d.lock.Unlock()
for _, id := range deleteIds {
for i, t := range d.tasks {
if t.ID == id {
d.tasks = append(d.tasks[:i], d.tasks[i+1:]...)
break
}
}
for i, t := range d.waitTasks {
if t.ID == id {
d.waitTasks = append(d.waitTasks[:i], d.waitTasks[i+1:]...)
break
}
}
}
}()
for _, task := range deleteTasksPtr {
err = d.doDelete(task, force)
if err != nil {
return
}
}
d.notifyRunning()
return
}
func (d *Downloader) deleteAll(force bool) (err error) {
var deleteTasksTemp []*Task
func() {
d.lock.Lock()
defer d.lock.Unlock()
for _, task := range d.tasks {
deleteTasksTemp = append(deleteTasksTemp, task)
}
d.tasks = make([]*Task, 0)
d.waitTasks = make([]*Task, 0)
}()
for _, task := range deleteTasksTemp {
if err = d.doDelete(task, force); err != nil {
return
}
}
return
}
func (d *Downloader) Stats(id string) (sr any, err error) {
task := d.GetTask(id)
if task == nil {
return sr, ErrTaskNotFound
}
if task.fetcher == nil {
err = func() error {
task.statusLock.Lock()
defer task.statusLock.Unlock()
return d.restoreFetcher(task)
}()
if err != nil {
return
}
}
sr = task.fetcher.Stats()
return
}
func (d *Downloader) doDelete(task *Task, force bool) (err error) {
defer d.releaseBlobTask(task)
err = func() error {
if err := d.storage.Delete(bucketTask, task.ID); err != nil {
return err
}
if err := d.storage.Delete(bucketSave, task.ID); err != nil {
return err
}
if task.fetcher != nil {
if err := task.fetcher.Close(); err != nil {
return err
}
}
if force && task.Meta.Res != nil {
if task.Meta.Res.Name != "" {
if err := os.RemoveAll(task.Meta.FolderPath()); err != nil {
return err
}
} else {
if err := util.SafeRemove(task.Meta.SingleFilepath()); err != nil {
return err
}
}
}
d.emit(EventKeyDelete, task)
task = nil
return nil
}()
if err != nil {
d.Logger.Error().Stack().Err(err).Msgf("delete task failed, task id: %s", task.ID)
}
return
}
func (d *Downloader) Close() error {
d.closed.Store(true)
closeArr := []func() error{
d.pauseAll,
}
for _, fm := range d.cfg.FetchManagers {
closeArr = append(closeArr, fm.Close)
}
if d.blob != nil {
closeArr = append(closeArr, d.blob.Close)
}
closeArr = append(closeArr, d.storage.Close)
// Make sure all resources are released, if had error, return the last error
var lastErr error
for i, close := range closeArr {
if err := close(); err != nil {
lastErr = err
d.Logger.Error().Stack().Err(err).Msgf("downloader close failed, index: %d", i)
}
}
return lastErr
}
func (d *Downloader) Clear() error {
if !d.closed.Load() {
if err := d.Close(); err != nil {
return err
}
}
d.tasks = make([]*Task, 0)
d.extensions = make([]*Extension, 0)
if err := d.storage.Clear(); err != nil {
return err
}
return nil
}
type protocolStateStore struct {
storage Storage
protocol string
}
func (s *protocolStateStore) Load(v any) (bool, error) {
return s.storage.Get(bucketProtocolState, s.protocol, v)
}
func (s *protocolStateStore) Save(v any) error {
if v == nil {
return s.Delete()
}
return s.storage.Put(bucketProtocolState, s.protocol, v)
}
func (s *protocolStateStore) Delete() error {
return s.storage.Delete(bucketProtocolState, s.protocol)
}
func (d *Downloader) Listener(fn Listener) {
d.listener = fn
}
func (d *Downloader) emit(eventKey EventKey, task *Task, errs ...error) {
if d.listener != nil {
var err error
if len(errs) > 0 {
err = errs[0]
}
d.listener(&Event{
Key: eventKey,
Task: task,
Err: err,
})
}
}
func (d *Downloader) GetTask(id string) *Task {
d.lock.Lock()
defer d.lock.Unlock()
for _, task := range d.tasks {
if task.ID == id {
return task
}
}
return nil
}
func (d *Downloader) GetTasks() []*Task {
d.lock.Lock()
defer d.lock.Unlock()
return d.tasks
}
// GetTasksByFilter get tasks by filter, if filter is nil, return all tasks
// return tasks and if match all tasks
func (d *Downloader) GetTasksByFilter(filter *TaskFilter) []*Task {
d.lock.Lock()
defer d.lock.Unlock()
if filter == nil || filter.IsEmpty() {
return d.tasks
}
idMatch := func(task *Task) bool {
if len(filter.IDs) == 0 {
return true
}
for _, id := range filter.IDs {
if task.ID == id {
return true
}
}
return false
}
statusMatch := func(task *Task) bool {
if len(filter.Statuses) == 0 {
return true
}
for _, status := range filter.Statuses {
if task.Status == status {
return true
}
}
return false
}
notStatusMatch := func(task *Task) bool {
if len(filter.NotStatuses) == 0 {
return true
}
for _, status := range filter.NotStatuses {
if task.Status == status {
return false
}
}
return true
}
tasks := make([]*Task, 0)
for _, task := range d.tasks {
if idMatch(task) && statusMatch(task) && notStatusMatch(task) {
tasks = append(tasks, task)
}
}
return tasks
}
func (d *Downloader) GetConfig() (*base.DownloaderStoreConfig, error) {
return d.cfg.DownloaderStoreConfig, nil
}
func (d *Downloader) PutConfig(v *base.DownloaderStoreConfig) error {
d.cfg.DownloaderStoreConfig = v
return d.storage.Put(bucketConfig, "config", v)
}
func (d *Downloader) getProtocolConfig(name string, v any) bool {
cfg, err := d.GetConfig()
if err != nil {
return false
}
if cfg.ProtocolConfig == nil || cfg.ProtocolConfig[name] == nil {
return false
}
if err := util.MapToStruct(cfg.ProtocolConfig[name], v); err != nil {
d.Logger.Warn().Err(err).Msgf("get protocol config failed")
return false
}
return true
}
// wait task done
func (d *Downloader) watch(task *Task) {
if _, loaded := d.watchedTasks.LoadOrStore(task.ID, true); loaded {
return
}
defer func() {
d.watchedTasks.Delete(task.ID)
}()
// wait task upload done
if task.Uploading {
if uploader, ok := task.fetcher.(fetcher.Uploader); ok {
go func() {
err := uploader.WaitUpload()
if err != nil {
d.Logger.Warn().Err(err).Msgf("task wait upload failed, task id: %s", task.ID)
}
// Check if the task is deleted
if d.GetTask(task.ID) != nil {
task.Uploading = false
d.storage.Put(bucketTask, task.ID, task.clone())
}
}()
}
}
if d.taskStatus(task) == base.DownloadStatusDone {
return
}
for {
err := task.fetcher.Wait()
if err != nil {
status := d.taskStatus(task)
if status == base.DownloadStatusPause || status == base.DownloadStatusWait {
return
}
var sourceErr error
if d.blob != nil && task.Meta != nil && task.Meta.Req != nil {
sourceErr = d.blob.SourceError(task.Meta.Req.URL)
}
if sourceErr != nil {
d.doOnBlobSourceError(task, sourceErr)
} else {
d.doOnError(task, err)
}
if d.taskStatus(task) == base.DownloadStatusRunning {
continue
}
return
}
// When delete a not resolved task, need check if the task resource is nil
if task.Meta.Res == nil || d.GetTask(task.ID) == nil {
return
}
if d.blob != nil && task.Meta.Req != nil {
if err := d.blob.SourceError(task.Meta.Req.URL); err != nil {
d.doOnBlobSourceError(task, err)
if d.taskStatus(task) == base.DownloadStatusRunning {
continue
}
return
}
}
task.Progress.Used = task.timer.Used()
if task.Meta.Res.Size == 0 {
task.Meta.Res.Size = task.fetcher.Progress().TotalDownloaded()
}
used := task.Progress.Used / int64(time.Second)
if used == 0 {
used = 1
}
totalSize := task.Meta.Res.Size
task.Progress.Speed = totalSize / used
task.Progress.Downloaded = totalSize
if !d.markTaskDone(task) {
return
}
d.storage.Put(bucketTask, task.ID, task.clone())
d.emit(EventKeyDone, task)
d.emit(EventKeyFinally, task, err)
d.notifyRunning()
d.releaseBlobTask(task)
d.triggerOnDone(task)
d.triggerWebhooks(WebhookEventDownloadDone, task, nil)
d.triggerScripts(ScriptEventDownloadDone, task, nil)
if e, ok := task.Meta.Opts.Extra.(*http.OptsExtra); ok {
downloadFilePath := task.Meta.SingleFilepath()
cfg, _ := d.GetConfig()
// Determine if auto-torrent is enabled (use global config if not explicitly set)
autoTorrentEnabled := false
if e.AutoTorrent != nil {
autoTorrentEnabled = *e.AutoTorrent
} else if cfg != nil && cfg.AutoTorrent != nil {
autoTorrentEnabled = cfg.AutoTorrent.Enable
}
if autoTorrentEnabled && strings.HasSuffix(downloadFilePath, ".torrent") {
// Determine if should delete torrent file after creating BT task
shouldDelete := false
if e.DeleteTorrentAfterDownload != nil {
shouldDelete = *e.DeleteTorrentAfterDownload
} else if cfg != nil && cfg.AutoTorrent != nil {
shouldDelete = cfg.AutoTorrent.DeleteAfterDownload
}
go func() {
_, err2 := d.CreateDirect(
&base.Request{
URL: downloadFilePath,
},
&base.Options{
Path: task.Meta.Opts.Path,
SelectFiles: make([]int, 0),
})
if err2 != nil {
d.Logger.Error().Err(err2).Msgf("auto create torrent task failed, task id: %s", task.ID)
return
}
if shouldDelete {
d.Delete(&TaskFilter{IDs: []string{task.ID}}, true)
}
}()
}
// Determine if auto-extract is enabled (use global config if not explicitly set)
autoExtractEnabled := false
if e.AutoExtract != nil {
autoExtractEnabled = *e.AutoExtract
} else if cfg != nil && cfg.Archive != nil {
autoExtractEnabled = cfg.Archive.AutoExtract
}
// Auto-extract archive files using the extraction queue
// This ensures only one extraction runs at a time to prevent resource exhaustion
if autoExtractEnabled && isArchiveFile(downloadFilePath) {
d.enqueueExtraction(task, downloadFilePath, e)
}
}
return
}
}
func (d *Downloader) doOnError(task *Task, err error) {
d.handleOnError(task, err, false)
}
func (d *Downloader) doOnBlobSourceError(task *Task, err error) {
d.handleOnError(task, err, true)
}
func (d *Downloader) handleOnError(task *Task, err error, resetFetcher bool) {
if task == nil || task.lock == nil {
return
}
task.lock.Lock()
if !d.markTaskError(task) {
task.lock.Unlock()
return
}
d.Logger.Warn().Err(err).Msgf("task download failed, task id: %s", task.ID)
oldURL := ""
if task.Meta != nil && task.Meta.Req != nil {
oldURL = task.Meta.Req.URL
}
d.triggerOnError(task, err)
newURL := ""
if task.Meta != nil && task.Meta.Req != nil {
newURL = task.Meta.Req.URL
}
if oldURL != "" && newURL != oldURL {
// Extensions are allowed to replace an expired URL from onError. Patch the
// existing fetcher through its public API so protocol-owned redirect state
// is reset without teaching the HTTP fetcher about blob URLs.
if task.fetcher != nil {
if patchErr := task.fetcher.Patch(&base.Request{URL: newURL}, nil); patchErr != nil {
d.Logger.Warn().Err(patchErr).Msgf("patch recovered task url failed, task id: %s", task.ID)
}
}
d.releaseBlobTaskURL(task, oldURL)
}
if resetFetcher && d.taskStatus(task) == base.DownloadStatusRunning {
if resetErr := d.resetTaskFetcher(task); resetErr != nil {
d.Logger.Warn().Err(resetErr).Msgf("reset recovered task fetcher failed, task id: %s", task.ID)
}
}
task.lock.Unlock()
if d.taskStatus(task) == base.DownloadStatusError {
d.releaseBlobTask(task)
d.emit(EventKeyError, task, err)
d.emit(EventKeyFinally, task, err)
d.notifyRunning()
d.triggerWebhooks(WebhookEventDownloadError, task, err)
}
}
func (d *Downloader) resetTaskFetcher(task *Task) error {
if task == nil || task.Meta == nil || task.Meta.Req == nil {
return errors.New("task request is unavailable")
}
fm, err := d.parseFm(task.Meta.Req.URL)
if err != nil {
return err
}
opts := task.Meta.Opts
f := fm.Build()
d.setupFetcher(fm, f)
// A fresh source must never inherit bytes or preallocation from the failed
// source. HTTP opens existing targets without truncating them, so remove the
// old single-file target before installing a fresh fetcher.
if task.Meta.Res != nil && task.Meta.Opts != nil && len(task.Meta.Res.Files) > 0 {
if err := util.SafeRemove(task.Meta.SingleFilepath()); err != nil {
return err
}
}
f.Meta().Req = task.Meta.Req
f.Meta().Opts = opts
task.fetcherManager = fm
task.fetcher = f
task.Meta = f.Meta()
if task.Progress != nil {
task.Progress.Downloaded = 0
task.Progress.Speed = 0
}
return d.storage.Delete(bucketSave, task.ID)
}
func (d *Downloader) restoreTask(task *Task) error {
if task.fetcher == nil {
if err := d.restoreFetcher(task); err != nil {
return err
}
}
return nil
}
func (d *Downloader) restoreFetcher(task *Task) error {
v, f := task.fetcherManager.Restore()
if v != nil {
err := d.storage.Pop(bucketSave, task.ID, v)
if err != nil {
return err
}
}
task.fetcher = f(task.Meta, v)
if task.fetcher == nil {
task.fetcher = task.fetcherManager.Build()
}
d.setupFetcher(task.fetcherManager, task.fetcher)
if task.fetcher.Meta().Req == nil {
task.fetcher.Meta().Req = task.Meta.Req
}
if task.fetcher.Meta().Res == nil {
task.fetcher.Meta().Res = task.Meta.Res
}
if task.fetcher.Meta().Opts == nil {
task.fetcher.Meta().Opts = task.Meta.Opts
}
return nil
}
func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId string, err error) {
if f.Meta().Opts == nil {
f.Meta().Opts = opts
}
ensureRequestRawURL(f.Meta().Req)
fm, err := d.parseFm(f.Meta().Req.URL)
if err != nil {
return
}
task := NewTask()
task.fetcherManager = fm
task.fetcher = f
task.Protocol = fm.Name()
task.Meta = f.Meta()
task.Progress = &Progress{}
_, task.Uploading = f.(fetcher.Uploader)
initTask(task)
if err = d.syncBlobTaskLease(task); err != nil {
return "", err
}
defer func() {
if err != nil {
d.releaseBlobTask(task)
}
}()
if err = d.storage.Put(bucketTask, task.ID, task.clone()); err != nil {
return
}
taskId = task.ID
func() {
d.lock.Lock()
defer d.lock.Unlock()
d.tasks = append(d.tasks, task)
remainRunningCount := d.remainRunningCount()
if remainRunningCount == 0 {
task.Status = base.DownloadStatusWait
d.waitTasks = append(d.waitTasks, task)
return
}
err = d.doStart(task)
}()
return
}
func (d *Downloader) initOptions(opts *base.Options) (*base.Options, error) {
if opts == nil {
opts = &base.Options{}
}
if opts.SelectFiles == nil {
opts.SelectFiles = make([]int, 0)
}
if opts.Path == "" {
storeConfig, err := d.GetConfig()
if err != nil {
return nil, err
}
opts.Path = storeConfig.DownloadDir
}
// Replace placeholders in download path (e.g., %year%, %month%, %day%, %date%)
opts.Path = util.ReplacePathPlaceholders(opts.Path)
// if enable white download directory, check if the download directory is in the white list
if len(d.cfg.WhiteDownloadDirs) > 0 {
inWhiteList := false
for _, dir := range d.cfg.WhiteDownloadDirs {
if match, err := filepath.Match(dir, opts.Path); match && err == nil {
inWhiteList = true
break
}
}
if !inWhiteList {
return nil, errors.New("download directory is not in white list")
}
}
return opts, nil
}
func (d *Downloader) statusMut(task *Task, fn func() (bool, error)) (bool, error) {
task.statusLock.Lock()
defer task.statusLock.Unlock()
return fn()
}
func (d *Downloader) doStart(task *Task) (err error) {
var isCreate bool
var generation uint64
isReturn, err := d.statusMut(task, func() (isReturn bool, err error) {
if task.Status == base.DownloadStatusRunning || task.Status == base.DownloadStatusDone {
isReturn = true
return
}
err = d.restoreTask(task)
if err != nil {
d.Logger.Error().Stack().Err(err).Msgf("restore fetcher failed, task id: %s", task.ID)
return
}
isCreate = task.Status == base.DownloadStatusReady
task.updateStatus(base.DownloadStatusRunning)
task.runGeneration++
generation = task.runGeneration
return
})
if err != nil {
d.Logger.Error().Stack().Err(err).Msgf("start task failed, task id: %s", task.ID)
return
}
if isReturn {
return
}
started := false
handler := func() error {
task.lock.Lock()
defer task.lock.Unlock()
if !d.taskIsRunningGeneration(task, generation) {
return nil
}
if task.fetcher == nil {
if err := d.restoreFetcher(task); err != nil {
return err
}
}
d.triggerOnStart(task)
if !d.taskIsRunningGeneration(task, generation) {
return nil
}
if task.fetcher != nil {
task.fetcher.Meta().Req = task.Meta.Req
task.fetcher.Meta().Res = task.Meta.Res
task.fetcher.Meta().Opts = task.Meta.Opts
}
if err := d.syncBlobTaskLease(task); err != nil {
return err
}
if task.Meta.Res == nil {
err := task.fetcher.Resolve(task.Meta.Req, task.Meta.Opts)
if err != nil {
return err
}
task.Meta.Res = task.fetcher.Meta().Res
}
if isCreate {
if task.fetcherManager.AutoRename() {
d.checkDuplicateLock.Lock()
defer d.checkDuplicateLock.Unlock()
task.Meta.Opts.Name = util.SafeFilename(task.Meta.Opts.Name)
// check if the download file is duplicated and rename it automatically.
if task.Meta.Res.Name != "" {
task.Meta.Res.Name = util.SafeFilename(task.Meta.Res.Name)
fullDirPath := task.Meta.FolderPath()
newName, err := util.CheckDuplicateAndRename(fullDirPath)
if err != nil {
return err
}
task.Meta.Opts.Name = newName
} else {
task.Meta.Res.Files[0].Name = util.SafeFilename(task.Meta.Res.Files[0].Name)
fullFilePath := task.Meta.SingleFilepath()
newName, err := util.CheckDuplicateAndRename(fullFilePath)
if err != nil {
return err
}
task.Meta.Opts.Name = newName
}
}
task.Meta.Res.CalcSize(task.Meta.Opts.SelectFiles)
}
task.Progress.Speed = 0
if !d.taskIsRunningGeneration(task, generation) {
return nil
}
task.timer.Start()
if err := task.fetcher.Start(); err != nil {
return err
}
started = true
if err := d.saveTask(task); err != nil {
return err
}
return nil
}
go func() {
if err := handler(); err != nil {
if d.taskIsRunningGeneration(task, generation) {
d.doOnError(task, err)
}
return
}
if started {
if d.taskIsRunningGeneration(task, generation) {
d.emit(EventKeyStart, task)
}
go d.watch(task)
}
}()
return
}
func (d *Downloader) doPause(task *Task) (err error) {
generation, isReturn, err := d.preparePause(task)
if err != nil || isReturn {
return err
}
handled, err := d.runPauseHandler(task, generation, true)
if err != nil {
d.Logger.Error().Stack().Err(err).Msgf("pause task handle failed, task id: %s", task.ID)
return err
}
if handled {
d.emit(EventKeyPause, task)
}
return nil
}
// doPauseForScheduling physically pauses a running task without emitting while
// the caller holds d.lock, then atomically moves it to the wait queue state.
func (d *Downloader) doPauseForScheduling(task *Task) (bool, error) {
generation, isReturn, err := d.preparePause(task)
if err != nil || isReturn {
return false, err
}
handled, err := d.runPauseHandler(task, generation, true)
if err != nil || !handled {
return false, err
}
task.statusLock.Lock()
queued := task.Status == base.DownloadStatusPause && task.runGeneration == generation
if queued {
task.Status = base.DownloadStatusWait
}
task.statusLock.Unlock()
return queued, nil
}
func (d *Downloader) preparePause(task *Task) (generation uint64, isReturn bool, err error) {
isReturn, err = d.statusMut(task, func() (isReturn bool, err error) {
if task.Status == base.DownloadStatusPause || task.Status == base.DownloadStatusDone {
isReturn = true
return
}
task.updateStatus(base.DownloadStatusPause)
task.runGeneration++
generation = task.runGeneration
task.timer.Pause()
return
})
if err != nil {
d.Logger.Error().Stack().Err(err).Msgf("pause task failed, task id: %s", task.ID)
}
return generation, isReturn, err
}
func (d *Downloader) runPauseHandler(task *Task, generation uint64, pauseFetcher bool) (bool, error) {
task.lock.Lock()
defer task.lock.Unlock()
if !d.taskIsPausedGeneration(task, generation) {
return false, nil
}
if pauseFetcher && task.fetcher != nil {
if err := task.fetcher.Pause(); err != nil {
return false, err
}
}
if task.fetcherManager != nil && task.fetcher != nil {
if err := d.saveTask(task); err != nil {
return false, err
}
} else {
if err := d.storage.Put(bucketTask, task.ID, task.clone()); err != nil {
return false, err
}
}
return true, nil
}
func (d *Downloader) taskIsRunningGeneration(task *Task, generation uint64) bool {
if task == nil || task.statusLock == nil {
return false
}
task.statusLock.Lock()
defer task.statusLock.Unlock()
return task.Status == base.DownloadStatusRunning && task.runGeneration == generation
}
func (d *Downloader) taskIsPausedGeneration(task *Task, generation uint64) bool {
if task == nil || task.statusLock == nil {
return false
}
task.statusLock.Lock()
defer task.statusLock.Unlock()
return (task.Status == base.DownloadStatusPause || task.Status == base.DownloadStatusWait) && task.runGeneration == generation
}
func (d *Downloader) taskStatus(task *Task) base.Status {
if task == nil || task.statusLock == nil {
return ""
}
task.statusLock.Lock()
defer task.statusLock.Unlock()
return task.Status
}
func (d *Downloader) markTaskDone(task *Task) bool {
if task == nil || task.statusLock == nil {
return false
}
task.statusLock.Lock()
defer task.statusLock.Unlock()
if task.Status != base.DownloadStatusRunning {
return false
}
task.updateStatus(base.DownloadStatusDone)
task.runGeneration++
return true
}
func (d *Downloader) markTaskError(task *Task) bool {
if task == nil || task.statusLock == nil {
return false
}
task.statusLock.Lock()
defer task.statusLock.Unlock()
if task.Status != base.DownloadStatusRunning {
return false
}
task.updateStatus(base.DownloadStatusError)
task.runGeneration++
return true
}
// redirect stderr to log file, when panic happened log it
func logPanic(logDir string) {
if err := util.CreateDirIfNotExist(logDir); err != nil {
return
}
f, err := os.Create(filepath.Join(logDir, "crash.log"))
if err != nil {
return
}
debug.SetCrashOutput(f, debug.CrashOptions{})
}
func (d *Downloader) assignFetcherManager(task *Task) error {
fm, err := d.parseFm(task.Meta.Req.URL)
if err != nil {
return err
}
task.fetcherManager = fm
return nil
}
func (d *Downloader) buildFetcher(url string) (fetcher.Fetcher, error) {
fm, err := d.parseFm(url)
if err != nil {
return nil, err
}
fetcher := fm.Build()
d.setupFetcher(fm, fetcher)
return fetcher, nil
}
// enqueueExtraction adds an extraction job to the global extraction queue
// This ensures only one extraction (or one multi-part archive extraction) runs at a time
// to prevent resource exhaustion
func (d *Downloader) enqueueExtraction(task *Task, downloadFilePath string, opts *http.OptsExtra) {
partInfo := getArchivePartInfo(downloadFilePath)
if partInfo.IsMultiPart {
// For multi-part archives, handle specially
d.enqueueMultiPartExtraction(task, downloadFilePath, partInfo, opts)
} else {
// For single archives, queue immediately
d.enqueueSingleExtraction(task, downloadFilePath, opts)
}
}
// enqueueSingleExtraction queues extraction for a single (non-multi-part) archive
func (d *Downloader) enqueueSingleExtraction(task *Task, downloadFilePath string, opts *http.OptsExtra) {
jobID := "single:" + task.ID
// Set extraction status to queued
task.Progress.ExtractStatus = ExtractStatusQueued
d.emit(EventKeyProgress, task)
d.storage.Put(bucketTask, task.ID, task.clone())
d.Logger.Info().Msgf("extraction queued, task id: %s, job id: %s", task.ID, jobID)
// Create and enqueue the extraction job
job := NewExtractionJob(jobID, func() {
d.performExtraction(task, downloadFilePath, task.Meta.Opts.Path, opts)
})
go func() {
GetExtractionQueue().Enqueue(job)
}()
}
// enqueueMultiPartExtraction handles queueing for multi-part archives
// It ensures only ONE extraction job is queued when ALL parts are ready
func (d *Downloader) enqueueMultiPartExtraction(task *Task, downloadFilePath string, partInfo ArchivePartInfo, opts *http.OptsExtra) {
// Set multi-part info on the task
task.Progress.MultiPartBaseName = partInfo.BaseName
task.Progress.MultiPartNumber = partInfo.PartNumber
task.Progress.MultiPartIsFirst = isFirstPart(downloadFilePath)
// Check if all parts are downloaded
destDir := task.Meta.Opts.Path
allPartsReady, missingParts := d.checkMultiPartArchiveReady(downloadFilePath, destDir, partInfo)
if !allPartsReady {
// Not all parts are ready yet - just set status to waiting, don't queue anything
task.Progress.ExtractStatus = ExtractStatusWaitingParts
d.emit(EventKeyProgress, task)
d.storage.Put(bucketTask, task.ID, task.clone())
d.Logger.Info().Msgf("multi-part archive waiting for other parts, task id: %s, missing: %v", task.ID, missingParts)
return
}
// All parts are ready! Atomically check if extraction has already been started/queued
// and if not, mark this task as the one that will handle it
// Use GetMultiPartArchiveBaseName to get the full path for comparison
fullBaseName := GetMultiPartArchiveBaseName(downloadFilePath)
shouldQueue := d.tryClaimMultiPartExtraction(task, fullBaseName)
if !shouldQueue {
// Another part already started/queued extraction, mark this task as done
task.Progress.ExtractStatus = ExtractStatusDone
task.Progress.ExtractProgress = 100
d.emit(EventKeyProgress, task)
d.storage.Put(bucketTask, task.ID, task.clone())
d.Logger.Info().Msgf("multi-part archive extraction already handled by another part, task id: %s", task.ID)
return
}
// This task claimed the extraction - status already set to queued in tryClaimMultiPartExtraction
d.emit(EventKeyProgress, task)
d.storage.Put(bucketTask, task.ID, task.clone())
jobID := "multipart:" + fullBaseName
d.Logger.Info().Msgf("multi-part extraction queued, task id: %s, job id: %s", task.ID, jobID)
// Create and enqueue the extraction job
job := NewExtractionJob(jobID, func() {
d.performMultiPartExtraction(task, partInfo.FirstPartPath, destDir, opts)
})
go func() {
GetExtractionQueue().Enqueue(job)
}()
}
// checkMultiPartArchiveReady checks if all parts of a multi-part archive are downloaded
// by examining task status rather than file existence
func (d *Downloader) checkMultiPartArchiveReady(filePath string, destDir string, partInfo ArchivePartInfo) (bool, []string) {
// Use task-based checking - find all tasks with the same MultiPartBaseName
// and verify they are all in Done status
baseName := GetMultiPartArchiveBaseName(filePath)
if baseName == "" {
return true, nil
}
return d.checkAllMultiPartTasksDone(baseName)
}
// checkAllMultiPartTasksDone checks if all tasks belonging to a multi-part archive are done
func (d *Downloader) checkAllMultiPartTasksDone(baseName string) (bool, []string) {
var notDoneParts []string
d.lock.Lock()
defer d.lock.Unlock()
// Find all tasks that belong to this multi-part archive
var relatedTasks []*Task
for _, task := range d.tasks {
taskBaseName := ""
if task.Meta != nil && task.Meta.Res != nil && len(task.Meta.Res.Files) > 0 {
taskBaseName = GetMultiPartArchiveBaseName(task.Meta.SingleFilepath())
}
if taskBaseName == baseName {
relatedTasks = append(relatedTasks, task)
}
}
// If we found no related tasks, we can't determine readiness
if len(relatedTasks) == 0 {
return false, []string{"no related tasks found for " + baseName}
}
// Check if all related tasks are done
for _, task := range relatedTasks {
if task.Status != base.DownloadStatusDone {
notDoneParts = append(notDoneParts, task.Meta.SingleFilepath())
}
}
return len(notDoneParts) == 0, notDoneParts
}
// tryClaimMultiPartExtraction atomically checks if extraction can be claimed for a multi-part archive
// and if so, marks the task as queued. Returns true if this task should proceed with queueing.
// This uses sync.Map.LoadOrStore for atomic claim to prevent race conditions.
func (d *Downloader) tryClaimMultiPartExtraction(task *Task, baseName string) bool {
// Use LoadOrStore for atomic claim - if another goroutine already stored a value, we get that value back
_, alreadyClaimed := d.claimedExtractions.LoadOrStore(baseName, task.ID)
if alreadyClaimed {
return false // Another task already claimed it
}
// This task successfully claimed it
task.Progress.ExtractStatus = ExtractStatusQueued
return true
}
// releaseMultiPartExtractionClaim releases the extraction claim for a multi-part archive
// This is primarily used for testing purposes
func (d *Downloader) releaseMultiPartExtractionClaim(baseName string) {
d.claimedExtractions.Delete(baseName)
}
// performExtraction performs extraction for a regular (non-multi-part) archive
func (d *Downloader) performExtraction(task *Task, archivePath string, destDir string, opts *http.OptsExtra) {
// Set extraction status to extracting
task.Progress.ExtractStatus = ExtractStatusExtracting
task.Progress.ExtractProgress = 0
d.emit(EventKeyProgress, task)
d.storage.Put(bucketTask, task.ID, task.clone())
// Extract the archive
extractErr := extractArchive(archivePath, destDir, opts.ArchivePassword, func(extractedFiles int, totalFiles int, progress int) {
task.Progress.ExtractProgress = progress
d.emit(EventKeyProgress, task)
})
d.handleExtractionResult(task, extractErr, []string{archivePath}, opts.DeleteAfterExtract)
}
// performMultiPartExtraction performs extraction for a multi-part archive
func (d *Downloader) performMultiPartExtraction(task *Task, firstPartPath string, destDir string, opts *http.OptsExtra) {
// Get the baseName for releasing the claim later
fullBaseName := GetMultiPartArchiveBaseName(firstPartPath)
// Set extraction status to extracting
task.Progress.ExtractStatus = ExtractStatusExtracting
task.Progress.ExtractProgress = 0
d.emit(EventKeyProgress, task)
d.storage.Put(bucketTask, task.ID, task.clone())
d.Logger.Info().Msgf("starting multi-part archive extraction, first part: %s, task id: %s", firstPartPath, task.ID)
// Extract the multi-part archive
extractErr := extractMultiPartArchive(firstPartPath, destDir, opts.ArchivePassword, func(extractedFiles int, totalFiles int, progress int) {
task.Progress.ExtractProgress = progress
d.emit(EventKeyProgress, task)
})
// Collect all part files for potential deletion
partFiles := d.collectMultiPartFiles(firstPartPath)
d.handleExtractionResult(task, extractErr, partFiles, opts.DeleteAfterExtract)
// Update status for all related multi-part tasks
d.updateMultiPartTasksStatus(task, extractErr)
// Release the claim so future downloads of the same archive can be extracted
d.releaseMultiPartExtractionClaim(fullBaseName)
}
// collectMultiPartFiles collects all files belonging to a multi-part archive
func (d *Downloader) collectMultiPartFiles(firstPartPath string) []string {
var files []string
partInfo := getArchivePartInfo(firstPartPath)
dir := filepath.Dir(firstPartPath)
switch {
case strings.Contains(partInfo.Pattern, ".7z)"):
// 7z: .7z.001, .7z.002, etc.
files = d.collectSequentialFiles(dir, partInfo.BaseName, ".%03d")
case strings.Contains(partInfo.Pattern, ".part"):
// RAR new style
files = d.collectRarNewStyleFiles(dir, partInfo.BaseName)
case partInfo.Pattern == "rar-old-style" || strings.Contains(partInfo.Pattern, ".r("):
// RAR old style
files = d.collectRarOldStyleFiles(dir, partInfo.BaseName)
case strings.Contains(partInfo.Pattern, ".zip)"):
// ZIP multi-part
files = d.collectSequentialFiles(dir, partInfo.BaseName, ".%03d")
case strings.Contains(partInfo.Pattern, ".z("):
// ZIP split
files = d.collectZipSplitFiles(dir, partInfo.BaseName)
}
return files
}
// collectSequentialFiles collects sequential numbered files
func (d *Downloader) collectSequentialFiles(dir, baseName, format string) []string {
var files []string
suffix := filepath.Ext(baseName)
nameWithoutExt := strings.TrimSuffix(baseName, suffix)
partNum := 1
for {
partPath := filepath.Join(dir, nameWithoutExt+suffix+fmt.Sprintf(format, partNum))
if _, err := os.Stat(partPath); os.IsNotExist(err) {
break
}
files = append(files, partPath)
partNum++
}
return files
}
// collectRarNewStyleFiles collects RAR new style part files
func (d *Downloader) collectRarNewStyleFiles(dir, baseName string) []string {
var files []string
partNum := 1
for {
singleDigitPath := filepath.Join(dir, baseName+fmt.Sprintf(".part%d.rar", partNum))
doubleDigitPath := filepath.Join(dir, baseName+fmt.Sprintf(".part%02d.rar", partNum))
if _, err := os.Stat(singleDigitPath); err == nil {
files = append(files, singleDigitPath)
} else if _, err := os.Stat(doubleDigitPath); err == nil {
files = append(files, doubleDigitPath)
} else {
break
}
partNum++
}
return files
}
// collectRarOldStyleFiles collects RAR old style part files
func (d *Downloader) collectRarOldStyleFiles(dir, baseName string) []string {
var files []string
// .rar file
rarPath := filepath.Join(dir, baseName+".rar")
if _, err := os.Stat(rarPath); err == nil {
files = append(files, rarPath)
}
// .r00, .r01, etc.
partNum := 0
for {
partPath := filepath.Join(dir, baseName+fmt.Sprintf(".r%02d", partNum))
if _, err := os.Stat(partPath); os.IsNotExist(err) {
break
}
files = append(files, partPath)
partNum++
}
return files
}
// collectZipSplitFiles collects ZIP split files
func (d *Downloader) collectZipSplitFiles(dir, baseName string) []string {
var files []string
// .z01, .z02, etc.
partNum := 1
for {
partPath := filepath.Join(dir, baseName+fmt.Sprintf(".z%02d", partNum))
if _, err := os.Stat(partPath); os.IsNotExist(err) {
break
}
files = append(files, partPath)
partNum++
}
// .zip file
zipPath := filepath.Join(dir, baseName+".zip")
if _, err := os.Stat(zipPath); err == nil {
files = append(files, zipPath)
}
return files
}
// handleExtractionResult handles the result of an extraction operation
func (d *Downloader) handleExtractionResult(task *Task, extractErr error, archiveFiles []string, deleteAfterExtract bool) {
if extractErr != nil {
d.Logger.Error().Err(extractErr).Msgf("auto extract archive failed, task id: %s", task.ID)
task.Progress.ExtractStatus = ExtractStatusError
d.emit(EventKeyProgress, task)
d.storage.Put(bucketTask, task.ID, task.clone())
} else {
d.Logger.Info().Msgf("auto extract archive completed, task id: %s", task.ID)
task.Progress.ExtractStatus = ExtractStatusDone
task.Progress.ExtractProgress = 100
d.emit(EventKeyProgress, task)
d.storage.Put(bucketTask, task.ID, task.clone())
// Delete archive files after successful extraction if enabled
if deleteAfterExtract {
for _, archiveFile := range archiveFiles {
deleteErr := os.Remove(archiveFile)
if deleteErr != nil {
d.Logger.Error().Err(deleteErr).Msgf("delete archive after extraction failed: %s", archiveFile)
} else {
d.Logger.Info().Msgf("archive deleted after extraction: %s", archiveFile)
}
}
}
}
}
// updateMultiPartTasksStatus updates the extraction status for all tasks that belong to the same multi-part archive
func (d *Downloader) updateMultiPartTasksStatus(sourceTask *Task, extractErr error) {
if sourceTask.Progress.MultiPartBaseName == "" {
return
}
status := ExtractStatusDone
progress := 100
if extractErr != nil {
status = ExtractStatusError
progress = 0
}
d.lock.Lock()
defer d.lock.Unlock()
for _, task := range d.tasks {
if task.ID == sourceTask.ID {
continue
}
if task.Progress.MultiPartBaseName == sourceTask.Progress.MultiPartBaseName {
task.Progress.ExtractStatus = status
task.Progress.ExtractProgress = progress
d.emit(EventKeyProgress, task)
d.storage.Put(bucketTask, task.ID, task.clone())
}
}
}
func initTask(task *Task) {
task.timer = util.NewTimer(task.Progress.Used)
task.statusLock = &sync.Mutex{}
task.lock = &sync.Mutex{}
task.blobRefLock = &sync.Mutex{}
task.speedArr = make([]int64, 0)
task.uploadSpeedArr = make([]int64, 0)
}
func (d *Downloader) releaseBlobTask(task *Task) {
if d.blob == nil || task == nil {
return
}
if task.blobRefLock != nil {
task.blobRefLock.Lock()
raw := task.blobURL
task.blobURL = ""
task.blobRefLock.Unlock()
if raw != "" {
_ = d.blob.Release(raw)
}
}
}
func (d *Downloader) syncBlobTaskLease(task *Task) error {
if d.blob == nil || task == nil || task.Meta == nil || task.Meta.Req == nil {
return nil
}
if task.blobRefLock == nil {
task.blobRefLock = &sync.Mutex{}
}
task.blobRefLock.Lock()
defer task.blobRefLock.Unlock()
terminal := false
if task.statusLock != nil {
task.statusLock.Lock()
terminal = task.Status == base.DownloadStatusDone || task.Status == base.DownloadStatusError
task.statusLock.Unlock()
}
newURL := ""
if !terminal && d.blob.IsURL(task.Meta.Req.URL) {
newURL = task.Meta.Req.URL
}
if task.blobURL == newURL {
return nil
}
if newURL != "" {
if err := d.blob.Acquire(newURL); err != nil {
return err
}
}
oldURL := task.blobURL
task.blobURL = newURL
if oldURL != "" {
_ = d.blob.Release(oldURL)
}
return nil
}
func (d *Downloader) releaseBlobTaskURL(task *Task, raw string) {
if d.blob == nil || task == nil || raw == "" {
return
}
if task.blobRefLock == nil {
task.blobRefLock = &sync.Mutex{}
}
task.blobRefLock.Lock()
claimed := task.blobURL == raw
if claimed {
task.blobURL = ""
}
task.blobRefLock.Unlock()
if claimed {
_ = d.blob.Release(raw)
}
}
var defaultDownloader = NewDownloader(nil)
type boot struct {
url string
extra interface{}
listener Listener
}
func (b *boot) URL(url string) *boot {
b.url = url
return b
}
func (b *boot) Extra(extra interface{}) *boot {
b.extra = extra
return b
}
func (b *boot) Listener(listener Listener) *boot {
b.listener = listener
return b
}
func (b *boot) Create(opts *base.Options) (string, error) {
defaultDownloader.Listener(b.listener)
return defaultDownloader.CreateDirect(&base.Request{
URL: b.url,
Extra: b.extra,
}, opts)
}
func Boot() *boot {
err := defaultDownloader.Setup()
if err != nil {
panic(err)
}
return &boot{}
}