feat: auto extract archives (#1212)

This commit is contained in:
Matteo Pietro Dazzi authored and GitHub committed 2025-12-29 17:27:49 +08:00
1 parent 5515a1c48c
commit 0d5c4e8e7f
35 files changed
+950 -40

No files matched your search

+20 -3
View File
@@ -2,13 +2,14 @@ package base
import (
"fmt"
"github.com/GopeedLab/gopeed/pkg/util"
"github.com/mattn/go-ieproxy"
"golang.org/x/exp/slices"
"net/http"
"net/url"
"sync"
"time"
"github.com/GopeedLab/gopeed/pkg/util"
"github.com/mattn/go-ieproxy"
"golang.org/x/exp/slices"
)
// Request download request
@@ -183,6 +184,7 @@ type DownloaderStoreConfig struct {
Extra map[string]any `json:"extra"`
Proxy *DownloaderProxyConfig `json:"proxy"`
Webhook *WebhookConfig `json:"webhook"` // Webhook is the webhook configuration
Archive *ArchiveConfig `json:"archive"` // Archive is the archive extraction configuration
}
func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
@@ -198,6 +200,12 @@ func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
if cfg.Webhook == nil {
cfg.Webhook = &WebhookConfig{}
}
if cfg.Archive == nil {
cfg.Archive = &ArchiveConfig{
AutoExtract: false,
DeleteAfterExtract: false,
}
}
return cfg
}
@@ -223,6 +231,9 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl
if cfg.Webhook == nil {
cfg.Webhook = beforeCfg.Webhook
}
if cfg.Archive == nil {
cfg.Archive = beforeCfg.Archive
}
return cfg
}
@@ -232,6 +243,12 @@ type WebhookConfig struct {
URLs []string `json:"urls"` // URLs is the list of webhook URLs
}
// ArchiveConfig is the archive extraction configuration
type ArchiveConfig struct {
AutoExtract bool `json:"autoExtract"` // AutoExtract enables automatic extraction of archives after download
DeleteAfterExtract bool `json:"deleteAfterExtract"` // DeleteAfterExtract deletes the archive after successful extraction
}
type DownloaderProxyConfig struct {
Enable bool `json:"enable"`
// System is the flag that use system proxy
+76
View File
@@ -19,6 +19,10 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
ProtocolConfig: map[string]any{},
Proxy: &DownloaderProxyConfig{},
Webhook: &WebhookConfig{},
Archive: &ArchiveConfig{
AutoExtract: false,
DeleteAfterExtract: false,
},
},
},
{
@@ -31,6 +35,10 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
ProtocolConfig: map[string]any{},
Proxy: &DownloaderProxyConfig{},
Webhook: &WebhookConfig{},
Archive: &ArchiveConfig{
AutoExtract: false,
DeleteAfterExtract: false,
},
},
},
{
@@ -47,6 +55,10 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
},
Proxy: &DownloaderProxyConfig{},
Webhook: &WebhookConfig{},
Archive: &ArchiveConfig{
AutoExtract: false,
DeleteAfterExtract: false,
},
},
},
{
@@ -63,6 +75,29 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
Enable: true,
},
Webhook: &WebhookConfig{},
Archive: &ArchiveConfig{
AutoExtract: false,
DeleteAfterExtract: false,
},
},
},
{
"Init Archive",
&DownloaderStoreConfig{
Archive: &ArchiveConfig{
AutoExtract: true,
DeleteAfterExtract: false,
},
},
&DownloaderStoreConfig{
MaxRunning: 5,
ProtocolConfig: map[string]any{},
Proxy: &DownloaderProxyConfig{},
Webhook: &WebhookConfig{},
Archive: &ArchiveConfig{
AutoExtract: true,
DeleteAfterExtract: false,
},
},
},
}
@@ -76,6 +111,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
Extra: tt.fields.Extra,
Proxy: tt.fields.Proxy,
Webhook: tt.fields.Webhook,
Archive: tt.fields.Archive,
}
if got := cfg.Init(); !reflect.DeepEqual(got, tt.want) {
t.Errorf("Init() = %v, want %v", got, tt.want)
@@ -250,6 +286,45 @@ func TestDownloaderStoreConfig_Merge(t *testing.T) {
},
},
},
{
"Merge Archive No Override",
&DownloaderStoreConfig{
Archive: &ArchiveConfig{
AutoExtract: true,
},
},
args{
beforeCfg: &DownloaderStoreConfig{
Archive: &ArchiveConfig{
AutoExtract: false,
DeleteAfterExtract: false,
},
},
},
&DownloaderStoreConfig{
Archive: &ArchiveConfig{
AutoExtract: true,
},
},
},
{
"Merge Archive Override",
&DownloaderStoreConfig{},
args{
beforeCfg: &DownloaderStoreConfig{
Archive: &ArchiveConfig{
AutoExtract: false,
DeleteAfterExtract: false,
},
},
},
&DownloaderStoreConfig{
Archive: &ArchiveConfig{
AutoExtract: false,
DeleteAfterExtract: false,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -261,6 +336,7 @@ func TestDownloaderStoreConfig_Merge(t *testing.T) {
Extra: tt.fields.Extra,
Proxy: tt.fields.Proxy,
Webhook: tt.fields.Webhook,
Archive: tt.fields.Archive,
}
if got := cfg.Merge(tt.args.beforeCfg); !reflect.DeepEqual(got, tt.want) {
t.Errorf("Merge() = %v, want %v", got, tt.want)
+23
View File
@@ -877,6 +877,29 @@ func (d *Downloader) watch(task *Task) {
}()
}
// Auto-extract archive files
if e.AutoExtract && isArchiveFile(downloadFilePath) {
go func() {
// Extract to the same directory as the downloaded file
destDir := task.Meta.Opts.Path
extractErr := extractArchive(downloadFilePath, destDir, e.ArchivePassword)
if extractErr != nil {
d.Logger.Error().Err(extractErr).Msgf("auto extract archive failed, task id: %s", task.ID)
} else {
d.Logger.Info().Msgf("auto extract archive completed, task id: %s", task.ID)
// Delete archive after successful extraction if enabled
if e.DeleteAfterExtract {
deleteErr := os.Remove(downloadFilePath)
if deleteErr != nil {
d.Logger.Error().Err(deleteErr).Msgf("delete archive after extraction failed, task id: %s", task.ID)
} else {
d.Logger.Info().Msgf("archive deleted after extraction, task id: %s", task.ID)
}
}
}
}()
}
}
}
+183
View File
@@ -0,0 +1,183 @@
package download
import (
"context"
"io"
"os"
"path/filepath"
"slices"
"strings"
"github.com/mholt/archives"
)
// supportedArchiveExtensions contains file extensions supported by mholt/archives library
var supportedArchiveExtensions = []string{
// Archive formats
".zip",
".tar",
".tar.gz", ".tgz",
".tar.bz2", ".tbz2",
".tar.xz", ".txz",
".tar.lz4", ".tlz4",
".tar.sz", ".tsz",
".tar.zst", ".tzst",
".rar",
".7z",
// Compression formats
".gz",
".bz2",
".xz",
".lz4",
".sz",
".zst",
".br",
".lz",
}
// isArchiveFile checks if a file is a supported archive format
func isArchiveFile(filename string) bool {
lowerName := strings.ToLower(filename)
return slices.ContainsFunc(supportedArchiveExtensions, func(ext string) bool {
return strings.HasSuffix(lowerName, ext)
})
}
// extractArchive extracts an archive file to a destination directory
func extractArchive(archivePath string, destDir string, password string) error {
// Open the archive file
file, err := os.Open(archivePath)
if err != nil {
return err
}
defer file.Close()
// Get file info for format identification
stat, err := file.Stat()
if err != nil {
return 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
}
// 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
}
if sz, ok := format.(archives.SevenZip); ok {
sz.Password = password
format = sz
}
// Note: Zip format in archives doesn't have a Password field
// Password-protected zips are not fully supported
}
// Handle extraction based on format type
switch f := 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)
})
case archives.Decompressor:
// For single-file compression formats (gz, bz2, xz, etc.)
// Decompress to a file without the compression extension
baseName := filepath.Base(archivePath)
lowerBaseName := strings.ToLower(baseName)
for _, ext := range supportedArchiveExtensions {
if strings.HasSuffix(lowerBaseName, ext) {
// Get the actual suffix from the original filename (preserving case)
actualSuffix := baseName[len(baseName)-len(ext):]
baseName = strings.TrimSuffix(baseName, actualSuffix)
break
}
}
destPath := filepath.Join(destDir, baseName)
reader, err := f.OpenReader(input)
if err != nil {
return err
}
defer reader.Close()
destFile, err := os.Create(destPath)
if err != nil {
return err
}
defer destFile.Close()
_, err = io.Copy(destFile, reader)
return err
case archives.Archiver:
// This format is an archiver, try to extract using the extractor interface
if ext, ok := format.(archives.Extractor); ok {
// Reset file position
if seeker, ok := 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)
})
}
}
return nil
}
// 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
if fileInfo.IsDir() {
destPath := filepath.Join(destDir, fileInfo.NameInArchive)
return os.MkdirAll(destPath, fileInfo.Mode())
}
// Sanitize the path to prevent path traversal attacks
cleanPath := filepath.Clean(fileInfo.NameInArchive)
if strings.HasPrefix(cleanPath, "..") || filepath.IsAbs(cleanPath) {
// Skip files with suspicious paths
return nil
}
destPath := filepath.Join(destDir, cleanPath)
// Create parent directories
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return err
}
// Open the file from the archive
reader, err := fileInfo.Open()
if err != nil {
return err
}
defer reader.Close()
// Create the destination file
destFile, err := os.Create(destPath)
if err != nil {
return err
}
defer destFile.Close()
// Copy the contents
_, err = io.Copy(destFile, reader)
if err != nil {
return err
}
// Set file permissions
return os.Chmod(destPath, fileInfo.Mode())
}
+154
View File
@@ -0,0 +1,154 @@
package download
import (
"archive/zip"
"os"
"path/filepath"
"testing"
)
func TestIsArchiveFile(t *testing.T) {
tests := []struct {
filename string
expected bool
}{
// Archive formats
{"file.zip", true},
{"file.ZIP", true},
{"file.tar", true},
{"file.tar.gz", true},
{"file.tgz", true},
{"file.tar.bz2", true},
{"file.tbz2", true},
{"file.tar.xz", true},
{"file.txz", true},
{"file.tar.zst", true},
{"file.tzst", true},
{"file.rar", true},
{"file.RAR", true},
{"file.7z", true},
// Compression formats
{"file.gz", true},
{"file.bz2", true},
{"file.xz", true},
{"file.lz4", true},
{"file.sz", true},
{"file.zst", true},
{"file.br", true},
{"file.lz", true},
// Non-archive files
{"file.txt", false},
{"file.pdf", false},
{"file.exe", false},
{"archive", false},
}
for _, tt := range tests {
t.Run(tt.filename, func(t *testing.T) {
result := isArchiveFile(tt.filename)
if result != tt.expected {
t.Errorf("isArchiveFile(%q) = %v, expected %v", tt.filename, result, tt.expected)
}
})
}
}
func TestExtractArchive_Zip(t *testing.T) {
// Create a temporary directory
tempDir, err := os.MkdirTemp("", "extract_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
// Create a test zip file
zipPath := filepath.Join(tempDir, "test.zip")
destDir := filepath.Join(tempDir, "extracted")
if err := createTestZip(zipPath); err != nil {
t.Fatal(err)
}
// Extract the archive
err = extractArchive(zipPath, destDir, "")
if err != nil {
t.Fatalf("extractArchive failed: %v", err)
}
// Verify the extracted files
expectedFiles := []string{
filepath.Join(destDir, "test.txt"),
filepath.Join(destDir, "subdir", "nested.txt"),
}
for _, path := range expectedFiles {
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Errorf("expected file %q not found after extraction", path)
}
}
// Verify content
content, err := os.ReadFile(filepath.Join(destDir, "test.txt"))
if err != nil {
t.Fatal(err)
}
if string(content) != "Hello, World!" {
t.Errorf("unexpected content: %q", string(content))
}
}
func TestExtractArchive_NonArchive(t *testing.T) {
// Create a temporary directory
tempDir, err := os.MkdirTemp("", "extract_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
// Create a non-archive file
txtPath := filepath.Join(tempDir, "test.txt")
if err := os.WriteFile(txtPath, []byte("not an archive"), 0644); err != nil {
t.Fatal(err)
}
destDir := filepath.Join(tempDir, "extracted")
// Trying to extract a non-archive should return an error
err = extractArchive(txtPath, destDir, "")
if err == nil {
t.Error("expected error when extracting non-archive file")
}
}
// createTestZip creates a test zip file with sample content
func createTestZip(path string) error {
zipFile, err := os.Create(path)
if err != nil {
return err
}
defer zipFile.Close()
w := zip.NewWriter(zipFile)
// Add a file to the root
f, err := w.Create("test.txt")
if err != nil {
return err
}
_, err = f.Write([]byte("Hello, World!"))
if err != nil {
return err
}
// Add a file in a subdirectory
f, err = w.Create("subdir/nested.txt")
if err != nil {
return err
}
_, err = f.Write([]byte("Nested content"))
if err != nil {
return err
}
return w.Close()
}
+6
View File
@@ -10,6 +10,12 @@ type OptsExtra struct {
Connections int `json:"connections"`
// AutoTorrent when task download complete, and it is a .torrent file, it will be auto create a new task for the torrent file
AutoTorrent bool `json:"autoTorrent"`
// AutoExtract when task download complete, and it is an archive file, it will be auto extracted
AutoExtract bool `json:"autoExtract"`
// ArchivePassword is the password for extracting password-protected archives
ArchivePassword string `json:"archivePassword"`
// DeleteAfterExtract when true, deletes the archive file after successful extraction
DeleteAfterExtract bool `json:"deleteAfterExtract"`
}
// Stats for download