diff --git a/frontend/src/component/FileManager/ContextMenu/ContextMenu.tsx b/frontend/src/component/FileManager/ContextMenu/ContextMenu.tsx index 263ecb67..1738e966 100644 --- a/frontend/src/component/FileManager/ContextMenu/ContextMenu.tsx +++ b/frontend/src/component/FileManager/ContextMenu/ContextMenu.tsx @@ -228,7 +228,7 @@ const ContextMenu = ({ fmIndex = 0 }: ContextMenuProps) => { )} {displayOpt.showExtractArchive && ( - dispatch(extractArchive(fmIndex, targets[0]))}> + dispatch(extractArchive(fmIndex, targets))}> diff --git a/frontend/src/component/FileManager/ContextMenu/useActionDisplayOpt.ts b/frontend/src/component/FileManager/ContextMenu/useActionDisplayOpt.ts index 3a37a4ab..4de3e843 100644 --- a/frontend/src/component/FileManager/ContextMenu/useActionDisplayOpt.ts +++ b/frontend/src/component/FileManager/ContextMenu/useActionDisplayOpt.ts @@ -13,6 +13,28 @@ import { FileManagerIndex } from "../FileManager.tsx"; const supportedArchiveTypes = ["zip", "gz", "xz", "tar", "rar", "7z", "bz2"]; +// archiveVolumeSetKey returns the volume-set key of a file name following the +// same conventions as the backend (partN.rar, .rNN), or null when the name is +// not a recognizable multi-volume member. +const archiveVolumeSetKey = (name: string): string | null => { + const l = name.toLowerCase(); + let m = /^(.+)\.part\d+\.rar$/.exec(l); + if (m) return `rar:${m[1]}`; + m = /^(.+)\.r\d+$/.exec(l); + if (m) return `rarold:${m[1]}`; + if (l.endsWith(".rar")) return `rarold:${l.slice(0, -4)}`; + return null; +}; + +// isSingleVolumeSet is true when all files are volumes of the same archive set. +export const isSingleVolumeSet = (files: FileResponse[]): boolean => { + if (files.length < 2 || files.some((f) => f.type != FileType.file)) { + return false; + } + const key = archiveVolumeSetKey(files[0].name); + return key != null && files.every((f) => archiveVolumeSetKey(f.name) === key); +}; + export const canManageVersion = (file: FileResponse, bs: Boolset) => { return ( file.type == FileType.file && @@ -267,12 +289,11 @@ export const getActionOpt = ( display.orCapability?.enabled(NavigatorCapability.enter_folder) && display.allReadable; display.showExtractArchive = - targets.length == 1 && display.hasFile && display.showDownload && !!currentUser && groupBs.enabled(GroupPermission.archive_task) && - supportedArchiveTypes.includes(firstFileSuffix ?? ""); + ((targets.length == 1 && supportedArchiveTypes.includes(firstFileSuffix ?? "")) || isSingleVolumeSet(targets)); display.showTorrentRemoteDownload = targets.length == 1 && display.hasFile && diff --git a/frontend/src/component/FileManager/Dialogs/ExtractArchive.tsx b/frontend/src/component/FileManager/Dialogs/ExtractArchive.tsx index 286c1ba0..3ddc5891 100644 --- a/frontend/src/component/FileManager/Dialogs/ExtractArchive.tsx +++ b/frontend/src/component/FileManager/Dialogs/ExtractArchive.tsx @@ -29,6 +29,7 @@ const ExtractArchive = () => { const open = useAppSelector((state) => state.globalState.extractArchiveDialogOpen); const target = useAppSelector((state) => state.globalState.extractArchiveDialogFile); + const targets = useAppSelector((state) => state.globalState.extractArchiveDialogFiles); const current = useAppSelector((state) => state.fileManager[FileManagerIndex.main].pure_path); const mask = useAppSelector((state) => state.globalState.extractArchiveDialogMask); const predefinedEncoding = useAppSelector((state) => state.globalState.extractArchiveDialogEncoding); @@ -44,7 +45,7 @@ const ExtractArchive = () => { const showPasswordOption = useMemo(() => { const ext = fileExtension(target?.name ?? ""); - return ext === "zip" || ext === "7z"; + return ext === "zip" || ext === "7z" || ext === "rar"; }, [target?.name]); useEffect(() => { @@ -65,7 +66,7 @@ const ExtractArchive = () => { setLoading(true); dispatch( sendExtractArchive({ - src: [getFileLinkedUri(target)], + src: (targets ?? [target]).map((f) => getFileLinkedUri(f)), dst: path, encoding: showEncodingOption && encoding != defaultEncodingValue ? encoding : undefined, password: showPasswordOption && password ? password : undefined, @@ -83,7 +84,7 @@ const ExtractArchive = () => { .finally(() => { setLoading(false); }); - }, [target, encoding, path, showPasswordOption, showEncodingOption, password, mask]); + }, [target, targets, encoding, path, showPasswordOption, showEncodingOption, password, mask]); return ( , + action: PayloadAction<{ open: boolean; file?: FileResponse; files?: FileResponse[]; mask?: string[]; encoding?: string }>, ) => { state.extractArchiveDialogOpen = action.payload.open; state.extractArchiveDialogFile = action.payload.file; + state.extractArchiveDialogFiles = action.payload.files; state.extractArchiveDialogMask = action.payload.mask; state.extractArchiveDialogEncoding = action.payload.encoding; }, closeExtractArchiveDialog: (state) => { state.extractArchiveDialogOpen = false; + state.extractArchiveDialogFiles = undefined; state.extractArchiveDialogMask = undefined; state.extractArchiveDialogEncoding = undefined; }, diff --git a/frontend/src/redux/thunks/file.ts b/frontend/src/redux/thunks/file.ts index ea1811b5..7a543d3a 100644 --- a/frontend/src/redux/thunks/file.ts +++ b/frontend/src/redux/thunks/file.ts @@ -1246,10 +1246,10 @@ export function walkAll(files: FileResponse[]): AppThunk { dispatch(closeContextMenu({ index, value: undefined })); - dispatch(setExtractArchiveDialog({ open: true, file })); + dispatch(setExtractArchiveDialog({ open: true, file: files[0], files })); }; } diff --git a/pkg/filemanager/workflows/extract.go b/pkg/filemanager/workflows/extract.go index b145a0aa..8a72ddb2 100644 --- a/pkg/filemanager/workflows/extract.go +++ b/pkg/filemanager/workflows/extract.go @@ -21,6 +21,7 @@ import ( "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs" "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs/dbfs" "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager" + "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource" "github.com/cloudreve/Cloudreve/v4/pkg/hashid" "github.com/cloudreve/Cloudreve/v4/pkg/logging" "github.com/cloudreve/Cloudreve/v4/pkg/queue" @@ -42,6 +43,7 @@ type ( ExtractArchiveTaskState struct { Uri string `json:"uri,omitempty"` Encoding string `json:"encoding,omitempty"` + VolumeUris []string `json:"volume_uris,omitempty"` Dst string `json:"dst,omitempty"` TempPath string `json:"temp_path,omitempty"` TempZipFilePath string `json:"temp_zip_file_path,omitempty"` @@ -72,15 +74,17 @@ func init() { queue.RegisterResumableTaskFactory(queue.ExtractArchiveTaskType, NewExtractArchiveTaskFromModel) } -// NewExtractArchiveTask creates a new ExtractArchiveTask -func NewExtractArchiveTask(ctx context.Context, src, dst, encoding, password string, mask []string) (queue.Task, error) { +// NewExtractArchiveTask creates a new ExtractArchiveTask. volumes optionally +// lists the URIs of all volumes of a multi-volume archive selected together. +func NewExtractArchiveTask(ctx context.Context, src, dst, encoding, password string, mask []string, volumes []string) (queue.Task, error) { state := &ExtractArchiveTaskState{ - Uri: src, - Dst: dst, - Encoding: encoding, - NodeState: NodeState{}, - Password: password, - FileMask: mask, + Uri: src, + Dst: dst, + Encoding: encoding, + NodeState: NodeState{}, + Password: password, + FileMask: mask, + VolumeUris: volumes, } stateBytes, err := json.Marshal(state) if err != nil { @@ -178,6 +182,10 @@ func (m *ExtractArchiveTask) createSlaveExtractTask(ctx context.Context, dep dep user := inventory.UserFromContext(ctx) fm := manager.NewFileManager(dep, user) + // A later volume may have been selected directly; extract from the first + // volume of the set when it exists. + uri = m.resolveFirstVolumeURI(ctx, fm, uri) + // Get entity source to extract archiveFile, err := fm.Get(ctx, uri, dbfs.WithFileEntities(), dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityDownloadFile), dbfs.WithNotRoot()) if err != nil { @@ -212,6 +220,7 @@ func (m *ExtractArchiveTask) createSlaveExtractTask(ctx context.Context, dep dep UserID: user.ID, Password: m.state.Password, FileMask: m.state.FileMask, + Volumes: m.resolveVolumeEntities(ctx, fm, uri.DirUri(), archiveFile.DisplayName(), entityModel), } payloadStr, err := json.Marshal(payload) @@ -271,6 +280,10 @@ func (m *ExtractArchiveTask) masterExtractArchive(ctx context.Context, dep depen user := inventory.UserFromContext(ctx) fm := manager.NewFileManager(dep, user) + // A later volume may have been selected directly; extract from the first + // volume of the set when it exists. + uri = m.resolveFirstVolumeURI(ctx, fm, uri) + // Get entity source to extract archiveFile, err := fm.Get(ctx, uri, dbfs.WithFileEntities(), dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityDownloadFile), dbfs.WithNotRoot()) if err != nil { @@ -344,8 +357,22 @@ func (m *ExtractArchiveTask) masterExtractArchive(ctx context.Context, dep depen extractor = zipExtractor } } - } else if rarExtractor, ok := extractor.(archives.Rar); ok && m.state.Password != "" { + } else if rarExtractor, ok := extractor.(archives.Rar); ok { rarExtractor.Password = m.state.Password + // Route volume reads through a resolver on the archive's folder so + // multi-volume archives (.partN.rar / .rNN) are followed across files. + rarExtractor.Name = archiveFile.DisplayName() + dir := uri.DirUri() + rarExtractor.FS = &archiveVolumeFS{open: func(name string) (entitysource.EntitySource, error) { + sibling, err := fm.Get(ctx, dir.Join(name), dbfs.WithFileEntities(), dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityDownloadFile), dbfs.WithNotRoot()) + if err != nil { + return nil, err + } + if sibling.PrimaryEntity() == nil { + return nil, fmt.Errorf("volume %q has no entity", name) + } + return fm.GetEntitySource(ctx, 0, fs.WithEntity(sibling.PrimaryEntity())) + }} extractor = rarExtractor } else if sevenZipExtractor, ok := extractor.(archives.SevenZip); ok && m.state.Password != "" { sevenZipExtractor.Password = m.state.Password @@ -555,6 +582,67 @@ func (m *ExtractArchiveTask) Cleanup(ctx context.Context) error { return nil } +// resolveFirstVolumeURI returns the URI of the first volume of the archive +// set the given URI belongs to. When the selected file already is the first +// volume, or the first volume is not found in the same folder, uri is +// returned unchanged. +func (m *ExtractArchiveTask) resolveFirstVolumeURI(ctx context.Context, fm manager.FileManager, uri *fs.URI) *fs.URI { + first := firstVolumeName(uri.Name()) + if first == "" { + return uri + } + candidate := uri.DirUri().Join(first) + if _, err := fm.Get(ctx, candidate, dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityDownloadFile), dbfs.WithNotRoot()); err != nil { + m.l.Info("Archive %q is a later volume but first volume %q is unavailable: %s", uri.Name(), first, err) + return uri + } + m.l.Info("Selected file %q is a later volume, extracting from first volume %q", uri.Name(), first) + return candidate +} + +// resolveVolumeEntities collects the entities of all sibling files in the +// same archive volume set, so a slave node can resolve later volumes without +// accessing the filesystem. The first volume entity is always included. +func (m *ExtractArchiveTask) resolveVolumeEntities(ctx context.Context, fm manager.FileManager, dir *fs.URI, archiveName string, archiveEntity *ent.Entity) map[string]*ent.Entity { + volumes := map[string]*ent.Entity{archiveName: archiveEntity} + key, _, isVolume := volumeInfo(archiveName) + + // Volumes explicitly selected with the archive. + for _, v := range m.state.VolumeUris { + vuri, err := fs.NewUriFromString(v) + if err != nil || volumes[vuri.Name()] != nil { + continue + } + volumeFile, err := fm.Get(ctx, vuri, dbfs.WithFileEntities(), dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityDownloadFile), dbfs.WithNotRoot()) + if err != nil || volumeFile.PrimaryEntity() == nil { + continue + } + volumes[volumeFile.DisplayName()] = volumeFile.PrimaryEntity().Model() + } + + // Sibling volumes present in the folder but not selected. + if isVolume { + if _, listed, err := fm.List(ctx, dir, &manager.ListArgs{PageSize: 1000}); err == nil { + for _, child := range listed.Files { + if volumes[child.DisplayName()] != nil { + continue + } + childKey, _, ok := volumeInfo(child.DisplayName()) + if !ok || childKey != key { + continue + } + volumeFile, err := fm.Get(ctx, dir.Join(child.DisplayName()), dbfs.WithFileEntities(), dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityDownloadFile), dbfs.WithNotRoot()) + if err != nil || volumeFile.PrimaryEntity() == nil { + continue + } + volumes[child.DisplayName()] = volumeFile.PrimaryEntity().Model() + } + } + } + + return volumes +} + type ( SlaveExtractArchiveTask struct { *queue.InMemoryTask @@ -566,17 +654,18 @@ type ( } SlaveExtractArchiveTaskState struct { - FileName string `json:"file_name"` - Entity *ent.Entity `json:"entity"` - Policy *ent.StoragePolicy `json:"policy"` - Encoding string `json:"encoding,omitempty"` - Dst string `json:"dst,omitempty"` - UserID int `json:"user_id"` - TempPath string `json:"temp_path,omitempty"` - TempZipFilePath string `json:"temp_zip_file_path,omitempty"` - ProcessedCursor string `json:"processed_cursor,omitempty"` - Password string `json:"password,omitempty"` - FileMask []string `json:"file_mask,omitempty"` + FileName string `json:"file_name"` + Entity *ent.Entity `json:"entity"` + Policy *ent.StoragePolicy `json:"policy"` + Encoding string `json:"encoding,omitempty"` + Dst string `json:"dst,omitempty"` + UserID int `json:"user_id"` + TempPath string `json:"temp_path,omitempty"` + TempZipFilePath string `json:"temp_zip_file_path,omitempty"` + ProcessedCursor string `json:"processed_cursor,omitempty"` + Password string `json:"password,omitempty"` + FileMask []string `json:"file_mask,omitempty"` + Volumes map[string]*ent.Entity `json:"volumes,omitempty"` } ) @@ -724,8 +813,18 @@ func (m *SlaveExtractArchiveTask) Do(ctx context.Context) (task.Status, error) { extractor = zipExtractor } } - } else if rarExtractor, ok := extractor.(archives.Rar); ok && m.state.Password != "" { + } else if rarExtractor, ok := extractor.(archives.Rar); ok { rarExtractor.Password = m.state.Password + // Resolve sibling volumes from the entities shipped with the task + // payload so multi-volume archives are followed across files. + rarExtractor.Name = m.state.FileName + rarExtractor.FS = &archiveVolumeFS{open: func(name string) (entitysource.EntitySource, error) { + model, ok := m.state.Volumes[name] + if !ok { + return nil, fmt.Errorf("volume %q not provided", name) + } + return fm.GetEntitySource(ctx, 0, fs.WithEntity(fs.NewEntity(model)), fs.WithPolicy(fm.CastStoragePolicyOnSlave(ctx, m.state.Policy))) + }} extractor = rarExtractor } else if sevenZipExtractor, ok := extractor.(archives.SevenZip); ok && m.state.Password != "" { sevenZipExtractor.Password = m.state.Password diff --git a/pkg/filemanager/workflows/volume.go b/pkg/filemanager/workflows/volume.go new file mode 100644 index 00000000..9d95004c --- /dev/null +++ b/pkg/filemanager/workflows/volume.go @@ -0,0 +1,141 @@ +package workflows + +import ( + "fmt" + "io" + iofs "io/fs" + "regexp" + "strconv" + "strings" + "time" + + "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs" + "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource" +) + +// Multi-volume archive naming conventions. rardecode derives the next volume +// name from the current one and opens it via a custom fs.FS, so siblings only +// need to be resolvable by bare file name within the archive's folder. +// Only RAR conventions are recognized: it is the format rardecode can follow +// across volumes via fs.FS. Split zip/7z sets are intentionally excluded as +// their extractors cannot span files. +var ( + newRarVolumeRe = regexp.MustCompile(`^(.+)\.part(\d+)\.rar$`) + oldRarVolumeRe = regexp.MustCompile(`^(.+)\.r(\d+)$`) +) + +// volumeInfo returns the volume-set key and the ordering number of a file +// name. ok is false when the name is not a recognizable multi-volume member. +// The key is only comparable within one folder. +func volumeInfo(name string) (key string, order int64, ok bool) { + lower := strings.ToLower(name) + if m := newRarVolumeRe.FindStringSubmatch(lower); m != nil { + n, _ := strconv.ParseInt(m[2], 10, 64) + return "rar:" + m[1], n, true + } + if m := oldRarVolumeRe.FindStringSubmatch(lower); m != nil { + n, _ := strconv.ParseInt(m[2], 10, 64) + return "rarold:" + m[1], n + 1, true + } + if stem, found := strings.CutSuffix(lower, ".rar"); found { + return "rarold:" + stem, 0, true + } + return "", 0, false +} + +// firstVolumeName returns the expected file name of the first volume of the +// set the given member belongs to, or "" when the name already is the first +// volume or is not a recognizable later volume. +func firstVolumeName(name string) string { + lower := strings.ToLower(name) + if m := newRarVolumeRe.FindStringSubmatchIndex(lower); m != nil { + if n, _ := strconv.Atoi(lower[m[4]:m[5]]); n > 1 { + return fmt.Sprintf("%s.part%0*d.rar", name[m[2]:m[3]], len(lower[m[4]:m[5]]), 1) + } + return "" + } + if m := oldRarVolumeRe.FindStringSubmatchIndex(lower); m != nil { + return name[m[2]:m[3]] + ".rar" + } + return "" +} + +// FirstVolumeURI inspects a multi-file selection of cloudreve URIs and, when +// all files are volumes of the same archive set in the same folder, returns +// the URI of the first volume to extract from. ok is false when the +// selection is not a single volume set. +func FirstVolumeURI(srcs []string) (src string, ok bool) { + if len(srcs) < 2 { + return "", false + } + var key, dir string + best, bestOrder := -1, int64(0) + for i, s := range srcs { + u, err := fs.NewUriFromString(s) + if err != nil { + return "", false + } + k, order, isVolume := volumeInfo(u.Name()) + if !isVolume { + return "", false + } + if i == 0 { + key, dir = k, u.Dir() + } else if k != key || u.Dir() != dir { + return "", false + } + if best < 0 || order < bestOrder { + best, bestOrder = i, order + } + } + return srcs[best], true +} + +// archiveVolumeFS is an fs.FS resolving bare archive volume file names to +// entity sources. rardecode calls Open with volume names derived from the +// first volume's name; a missing sibling must surface fs.ErrNotExist so the +// decoder treats it as the end of the archive. +type archiveVolumeFS struct { + open func(name string) (entitysource.EntitySource, error) +} + +func (f *archiveVolumeFS) Open(name string) (iofs.File, error) { + if name == "" || strings.ContainsRune(name, '/') || strings.ContainsRune(name, '\\') { + return nil, &iofs.PathError{Op: "open", Path: name, Err: iofs.ErrNotExist} + } + + es, err := f.open(name) + if err != nil || es == nil { + return nil, &iofs.PathError{Op: "open", Path: name, Err: iofs.ErrNotExist} + } + + return &archiveVolumeFile{es: es, name: name}, nil +} + +// archiveVolumeFile adapts an entity source to fs.File while keeping the +// io.Seeker passthrough rardecode uses for in-volume seeks. +type archiveVolumeFile struct { + es entitysource.EntitySource + name string +} + +func (f *archiveVolumeFile) Read(p []byte) (int, error) { return f.es.Read(p) } +func (f *archiveVolumeFile) Seek(o int64, w int) (int64, error) { return f.es.Seek(o, w) } +func (f *archiveVolumeFile) Close() error { return f.es.Close() } +func (f *archiveVolumeFile) Stat() (iofs.FileInfo, error) { + return archiveVolumeFileInfo{name: f.name, size: f.es.Entity().Size()}, nil +} + +var _ io.Seeker = (*archiveVolumeFile)(nil) + +type archiveVolumeFileInfo struct { + name string + size int64 +} + +func (i archiveVolumeFileInfo) Name() string { return i.name } +func (i archiveVolumeFileInfo) Size() int64 { return i.size } +func (i archiveVolumeFileInfo) Mode() iofs.FileMode { return 0 } +func (i archiveVolumeFileInfo) ModTime() time.Time { return time.Time{} } +func (i archiveVolumeFileInfo) IsDir() bool { return false } +func (i archiveVolumeFileInfo) Sys() any { return nil } diff --git a/pkg/filemanager/workflows/volume_test.go b/pkg/filemanager/workflows/volume_test.go new file mode 100644 index 00000000..24b885c7 --- /dev/null +++ b/pkg/filemanager/workflows/volume_test.go @@ -0,0 +1,83 @@ +package workflows + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestVolumeInfo(t *testing.T) { + cases := []struct { + name string + key string + order int64 + ok bool + }{ + {"movie.part1.rar", "rar:movie", 1, true}, + {"movie.part02.rar", "rar:movie", 2, true}, + {"MOVIE.PART10.RAR", "rar:movie", 10, true}, + {"movie.rar", "rarold:movie", 0, true}, + {"movie.r00", "rarold:movie", 1, true}, + {"movie.r15", "rarold:movie", 16, true}, + {"movie.zip", "", 0, false}, + {"movie.z01", "", 0, false}, + {"movie.001", "", 0, false}, + {"movie.7z", "", 0, false}, + {"movie.tar.gz", "", 0, false}, + {"readme.txt", "", 0, false}, + } + for _, c := range cases { + key, order, ok := volumeInfo(c.name) + require.Equal(t, c.ok, ok, c.name) + if c.ok { + require.Equal(t, c.key, key, c.name) + require.Equal(t, c.order, order, c.name) + } + } +} + +func TestFirstVolumeName(t *testing.T) { + require.Equal(t, "movie.part01.rar", firstVolumeName("movie.part07.rar")) + require.Equal(t, "movie.part1.rar", firstVolumeName("movie.part3.rar")) + require.Equal(t, "movie.rar", firstVolumeName("movie.r02")) + require.Equal(t, "", firstVolumeName("movie.part1.rar")) + require.Equal(t, "", firstVolumeName("movie.rar")) + require.Equal(t, "", firstVolumeName("movie.zip")) + require.Equal(t, "", firstVolumeName("movie.001")) + require.Equal(t, "", firstVolumeName("plain.txt")) +} + +func TestFirstVolumeURI(t *testing.T) { + base := "cloudreve://my/archives" + src, ok := FirstVolumeURI([]string{ + base + "/movie.part2.rar", + base + "/movie.part1.rar", + base + "/movie.part3.rar", + }) + require.True(t, ok) + require.Equal(t, base+"/movie.part1.rar", src) + + // Old-style set: .rar is first. + src, ok = FirstVolumeURI([]string{ + base + "/movie.r00", + base + "/movie.rar", + }) + require.True(t, ok) + require.Equal(t, base+"/movie.rar", src) + + // Single source never matches. + _, ok = FirstVolumeURI([]string{base + "/movie.part1.rar"}) + require.False(t, ok) + + // Mixed sets do not match. + _, ok = FirstVolumeURI([]string{base + "/a.part1.rar", base + "/b.part2.rar"}) + require.False(t, ok) + + // Different folders do not match. + _, ok = FirstVolumeURI([]string{base + "/movie.part1.rar", "cloudreve://my/other/movie.part2.rar"}) + require.False(t, ok) + + // Non-volume names do not match. + _, ok = FirstVolumeURI([]string{base + "/a.txt", base + "/b.txt"}) + require.False(t, ok) +} diff --git a/service/explorer/workflows.go b/service/explorer/workflows.go index 2e11c4eb..a80cee89 100644 --- a/service/explorer/workflows.go +++ b/service/explorer/workflows.go @@ -260,8 +260,17 @@ func (service *ArchiveWorkflowService) CreateExtractTask(c *gin.Context) (*TaskR return nil, serializer.NewError(serializer.CodeParamErr, "Invalid destination", err) } + // When multiple sources form a single multi-volume archive set, extract + // from the first volume and pass all volumes to the task. + src := service.Src[0] + var volumes []string + if first, ok := workflows.FirstVolumeURI(service.Src); ok { + src = first + volumes = service.Src + } + // Create task - t, err := workflows.NewExtractArchiveTask(c, service.Src[0], service.Dst, service.Encoding, service.Password, service.FileMask) + t, err := workflows.NewExtractArchiveTask(c, src, service.Dst, service.Encoding, service.Password, service.FileMask, volumes) if err != nil { return nil, serializer.NewError(serializer.CodeCreateTaskError, "Failed to create task", err) }