mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-07 09:00:47 +00:00
2180 lines
57 KiB
Go
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{}
|
|
}
|