feat: show extraction progress (#1217)

This commit is contained in:
Matteo Pietro Dazzi authored and GitHub committed 2025-12-31 09:25:21 +08:00
1 parent dbe37befa3
commit b0e133b554
27 files changed
+2591 -40

No files matched your search

+35 -1
View File
@@ -45,6 +45,20 @@ var (
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 = ""
// 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"`
@@ -56,6 +70,10 @@ type Progress struct {
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"`
}
type Downloader struct {
@@ -881,13 +899,29 @@ func (d *Downloader) watch(task *Task) {
// Auto-extract archive files
if e.AutoExtract && isArchiveFile(downloadFilePath) {
go func() {
// 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 to the same directory as the downloaded file
destDir := task.Meta.Opts.Path
extractErr := extractArchive(downloadFilePath, destDir, e.ArchivePassword)
extractErr := extractArchive(downloadFilePath, destDir, e.ArchivePassword, func(extractedFiles int, totalFiles int, progress int) {
task.Progress.ExtractProgress = progress
d.emit(EventKeyProgress, task)
})
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 after successful extraction if enabled
if e.DeleteAfterExtract {
deleteErr := os.Remove(downloadFilePath)
File diff suppressed because it is too large. Load diff
+119 -27
View File
@@ -3,10 +3,12 @@ package download
import (
"context"
"io"
"math"
"os"
"path/filepath"
"slices"
"strings"
"sync/atomic"
"github.com/mholt/archives"
)
@@ -35,6 +37,9 @@ var supportedArchiveExtensions = []string{
".lz",
}
// ExtractProgressCallback is called to report extraction progress
type ExtractProgressCallback func(extractedFiles int, totalFiles int, progress int)
// isArchiveFile checks if a file is a supported archive format
func isArchiveFile(filename string) bool {
lowerName := strings.ToLower(filename)
@@ -43,35 +48,35 @@ func isArchiveFile(filename string) bool {
})
}
// extractArchive extracts an archive file to a destination directory
func extractArchive(archivePath string, destDir string, password string) error {
// Open the archive file
// archiveInfo holds information about an opened archive
type archiveInfo struct {
file *os.File
stat os.FileInfo
format archives.Format
input io.Reader
}
// openArchive opens an archive file and identifies its format
func openArchive(archivePath string, password string) (*archiveInfo, error) {
file, err := os.Open(archivePath)
if err != nil {
return err
return nil, err
}
defer file.Close()
// Get file info for format identification
stat, err := file.Stat()
if err != nil {
return err
file.Close()
return nil, err
}
// Identify the archive format
format, input, err := archives.Identify(context.Background(), archivePath, file)
if err != nil {
return err
}
// Create destination directory if it doesn't exist
if err := os.MkdirAll(destDir, 0755); err != nil {
return err
file.Close()
return nil, err
}
// Handle password-protected archives
if password != "" {
// Try to set password for formats that support it
if rar, ok := format.(archives.Rar); ok {
rar.Password = password
format = rar
@@ -80,17 +85,57 @@ func extractArchive(archivePath string, destDir string, password string) error {
sz.Password = password
format = sz
}
// Note: Zip format in archives doesn't have a Password field
// Password-protected zips are not fully supported
}
return &archiveInfo{
file: file,
stat: stat,
format: format,
input: input,
}, nil
}
// createExtractionHandler creates a handler function for extracting files with progress tracking
func createExtractionHandler(destDir string, totalFiles int, progressCallback ExtractProgressCallback) func(ctx context.Context, fileInfo archives.FileInfo) error {
var extractedFiles atomic.Int32
return func(ctx context.Context, fileInfo archives.FileInfo) error {
err := extractFile(ctx, fileInfo, destDir)
if err == nil && !fileInfo.IsDir() {
extracted := int(extractedFiles.Add(1))
if progressCallback != nil && totalFiles > 0 {
progress := int(math.Min(float64((extracted*100)/totalFiles), 100))
progressCallback(extracted, totalFiles, progress)
}
}
return err
}
}
// extractArchive extracts an archive file to a destination directory
func extractArchive(archivePath string, destDir string, password string, progressCallback ExtractProgressCallback) error {
// Open the archive file
info, err := openArchive(archivePath, password)
if err != nil {
return err
}
defer info.file.Close()
// Create destination directory if it doesn't exist
if err := os.MkdirAll(destDir, 0755); err != nil {
return err
}
// Handle extraction based on format type
switch f := format.(type) {
switch f := info.format.(type) {
case archives.Extractor:
// For archive formats (zip, rar, 7z, tar, etc.)
return f.Extract(context.Background(), input, func(ctx context.Context, fileInfo archives.FileInfo) error {
return extractFile(ctx, fileInfo, destDir)
})
// First, count total files for progress tracking
totalFiles, err := countArchiveFiles(archivePath, password)
if err != nil {
// If counting fails, proceed without progress reporting
totalFiles = 0
}
return f.Extract(context.Background(), info.input, createExtractionHandler(destDir, totalFiles, progressCallback))
case archives.Decompressor:
// For single-file compression formats (gz, bz2, xz, etc.)
// Decompress to a file without the compression extension
@@ -106,7 +151,7 @@ func extractArchive(archivePath string, destDir string, password string) error {
}
destPath := filepath.Join(destDir, baseName)
reader, err := f.OpenReader(input)
reader, err := f.OpenReader(info.input)
if err != nil {
return err
}
@@ -118,24 +163,71 @@ func extractArchive(archivePath string, destDir string, password string) error {
}
defer destFile.Close()
// Report progress at start and end for decompression
if progressCallback != nil {
progressCallback(0, 1, 0)
}
_, err = io.Copy(destFile, reader)
if err == nil && progressCallback != nil {
progressCallback(1, 1, 100)
}
return err
case archives.Archiver:
// This format is an archiver, try to extract using the extractor interface
if ext, ok := format.(archives.Extractor); ok {
if ext, ok := info.format.(archives.Extractor); ok {
// Reset file position
if seeker, ok := input.(io.Seeker); ok {
if seeker, ok := info.input.(io.Seeker); ok {
seeker.Seek(0, io.SeekStart)
}
return ext.Extract(context.Background(), io.NewSectionReader(file, 0, stat.Size()), func(ctx context.Context, fileInfo archives.FileInfo) error {
return extractFile(ctx, fileInfo, destDir)
})
// Count total files for progress tracking
totalFiles, err := countArchiveFiles(archivePath, password)
if err != nil {
totalFiles = 0
}
return ext.Extract(context.Background(), io.NewSectionReader(info.file, 0, info.stat.Size()), createExtractionHandler(destDir, totalFiles, progressCallback))
}
}
return nil
}
// createCountingHandler creates a handler function for counting files in an archive
func createCountingHandler(count *int) func(ctx context.Context, fileInfo archives.FileInfo) error {
return func(ctx context.Context, fileInfo archives.FileInfo) error {
if !fileInfo.IsDir() {
*count++
}
return nil
}
}
// countArchiveFiles counts the number of files in an archive for progress calculation
func countArchiveFiles(archivePath string, password string) (int, error) {
info, err := openArchive(archivePath, password)
if err != nil {
return 0, err
}
defer info.file.Close()
count := 0
switch f := info.format.(type) {
case archives.Extractor:
err = f.Extract(context.Background(), info.input, createCountingHandler(&count))
case archives.Archiver:
if ext, ok := info.format.(archives.Extractor); ok {
if seeker, ok := info.input.(io.Seeker); ok {
seeker.Seek(0, io.SeekStart)
}
err = ext.Extract(context.Background(), io.NewSectionReader(info.file, 0, info.stat.Size()), createCountingHandler(&count))
}
case archives.Decompressor:
// Single file compression, count as 1
return 1, nil
}
return count, err
}
// extractFile handles extracting a single file from an archive
func extractFile(ctx context.Context, fileInfo archives.FileInfo, destDir string) error {
// Skip directories, they will be created when extracting files
File diff suppressed because it is too large. Load diff