diff --git a/internal/fs/seed_generate.go b/internal/fs/seed_generate.go index 86f326942..ed5a628b1 100644 --- a/internal/fs/seed_generate.go +++ b/internal/fs/seed_generate.go @@ -102,6 +102,61 @@ type SeedArtifact struct { Path string `json:"path,omitempty"` } +// DeriveSeedName derives a sensible default seed name from the source paths: +// single selection uses the file name, multi selection uses the common base +// name (ignoring extensions) when all files share one, otherwise the folder name. +func DeriveSeedName(paths []string) string { + if len(paths) == 0 { + return "OpenList Seed" + } + if len(paths) == 1 { + return seedBaseName(paths[0]) + } + // Common base name ignoring extensions (e.g. a.docx + a.exe -> "a"). + commonBase := seedBaseName(paths[0]) + for _, p := range paths[1:] { + if base := seedBaseName(p); base != commonBase { + commonBase = "" + break + } + } + if commonBase != "" { + return commonBase + } + // Fall back to the common parent directory name. + dir := commonParentDir(paths) + if base := stdpath.Base(dir); base != "" && base != "/" && base != "." { + return base + } + return "OpenList Seed" +} + +// seedBaseName returns the file name without its extension. +func seedBaseName(p string) string { + base := stdpath.Base(p) + return strings.TrimSuffix(base, stdpath.Ext(base)) +} + +// commonParentDir returns the longest common parent directory of the given paths. +func commonParentDir(paths []string) string { + if len(paths) == 0 { + return "/" + } + parts := strings.Split(strings.Trim(stdpath.Dir(paths[0]), "/"), "/") + for _, p := range paths[1:] { + cur := strings.Split(strings.Trim(stdpath.Dir(p), "/"), "/") + n := 0 + for n < len(parts) && n < len(cur) && parts[n] == cur[n] { + n++ + } + parts = parts[:n] + } + if len(parts) == 0 { + return "/" + } + return "/" + strings.Join(parts, "/") +} + // NormalizeSeedFormats validates and deduplicates a list of seed format names. func NormalizeSeedFormats(rawFormats []string) ([]string, error) { formats := append([]string(nil), rawFormats...) @@ -277,11 +332,7 @@ func GenerateSeedArtifacts(ctx context.Context, user *model.User, params SeedGen } seedName := strings.TrimSpace(params.Name) if seedName == "" { - if len(params.Paths) == 1 { - seedName = stdpath.Base(params.Paths[0]) - } else { - seedName = "OpenList Seed" - } + seedName = DeriveSeedName(params.Paths) } seed := torrent.NewSeed(seedName, "OpenList", pieceSize) seed.Comment = params.Comment @@ -491,26 +542,6 @@ func GenerateSeedArtifacts(ctx context.Context, user *model.User, params SeedGen } seenFormats[format] = struct{}{} - // CAS is inherently a single-file container: emit one .cas per file - // instead of rejecting multi-file selection. - if format == "cas" { - for _, file := range seed.Files { - singleSeed := *seed - singleSeed.Files = []torrent.SeedFile{file} - data, err := EncodeGeneratedSeed(&singleSeed, format, nil) - if err != nil { - return nil, nil, fmt.Errorf("generate %s seed for %s: %w", format, file.Path, err) - } - fileName := stdpath.Base(file.Path) + ".cas" - artifact, err := writeArtifact(format, fileName, data) - if err != nil { - return nil, nil, err - } - artifacts = append(artifacts, artifact) - } - continue - } - data, err := EncodeGeneratedSeed(seed, format, globalHasher.GetPieceHashes()) if err != nil { return nil, nil, fmt.Errorf("generate %s seed: %w", format, err) diff --git a/pkg/torrent/torrent.go b/pkg/torrent/torrent.go index 54a9bfea7..c7340ef9c 100644 --- a/pkg/torrent/torrent.go +++ b/pkg/torrent/torrent.go @@ -102,8 +102,8 @@ type SeedSource struct { ShareID string `json:"share_id,omitempty"` } -// CASPayload exactly matches the five-field reference .cas JSON payload. -type CASPayload struct { +// CASFileEntry describes one file inside a multi-file .cas payload. +type CASFileEntry struct { Name string `json:"name"` Size int64 `json:"size"` MD5 string `json:"md5"` @@ -111,6 +111,18 @@ type CASPayload struct { CreateTime string `json:"create_time"` } +// CASPayload matches the reference .cas JSON payload. The five legacy fields +// describe a single file (byte-for-byte compatible with the reference project); +// the optional "files" array extends it to multi-file seeds. +type CASPayload struct { + Name string `json:"name"` + Size int64 `json:"size"` + MD5 string `json:"md5"` + SliceMD5 string `json:"sliceMd5"` + CreateTime string `json:"create_time"` + Files []CASFileEntry `json:"files,omitempty"` +} + // ParseLimits controls resource use while parsing untrusted seeds. type ParseLimits struct { MaxBytes int64 @@ -804,20 +816,13 @@ func DecodeOSS(data []byte, limits ParseLimits) (*Seed, error) { return &seed, nil } -// EncodeCAS writes the reference-compatible base64 encoded JSON payload. -func EncodeCAS(seed *Seed) ([]byte, error) { - if err := ValidateSeed(seed, DefaultParseLimits()); err != nil { - return nil, err - } - if len(seed.Files) != 1 { - return nil, fmt.Errorf("CAS requires exactly one file") - } - file := seed.Files[0] +// buildCASFileEntry computes the reference-compatible five fields for one file. +func buildCASFileEntry(file SeedFile, pieceSize int64) (CASFileEntry, error) { if file.Hashes.MD5 == "" { - return nil, fmt.Errorf("CAS requires a whole-file MD5") + return CASFileEntry{}, fmt.Errorf("CAS requires a whole-file MD5 for %s", file.Path) } sliceMD5 := strings.ToUpper(file.CASSliceMD5) - if sliceMD5 == "" && file.Hashes.Pieces != nil && len(file.Hashes.Pieces.MD5) > 0 && seed.PieceSize == DefaultPieceSize { + if sliceMD5 == "" && file.Hashes.Pieces != nil && len(file.Hashes.Pieces.MD5) > 0 && pieceSize == DefaultPieceSize { pieces := upperStrings(file.Hashes.Pieces.MD5) sliceMD5 = pieces[0] if len(pieces) > 1 { @@ -826,7 +831,7 @@ func EncodeCAS(seed *Seed) ([]byte, error) { } if sliceMD5 == "" { if file.Size > DefaultPieceSize { - return nil, fmt.Errorf("CAS requires a legacy slice MD5 or complete 10 MiB MD5 pieces") + return CASFileEntry{}, fmt.Errorf("CAS requires a legacy slice MD5 or complete 10 MiB MD5 pieces for %s", file.Path) } sliceMD5 = strings.ToUpper(file.Hashes.MD5) } @@ -834,9 +839,44 @@ func EncodeCAS(seed *Seed) ([]byte, error) { if createTime == "" { createTime = fmt.Sprintf("%d", time.Now().Unix()) } - payload := CASPayload{ + return CASFileEntry{ Name: path.Base(file.Path), Size: file.Size, MD5: strings.ToUpper(file.Hashes.MD5), SliceMD5: sliceMD5, CreateTime: createTime, + }, nil +} + +// EncodeCAS writes the reference-compatible base64 encoded JSON payload. A +// single-file seed uses the legacy five fields; multiple files are stored in +// the "files" array. +func EncodeCAS(seed *Seed) ([]byte, error) { + if err := ValidateSeed(seed, DefaultParseLimits()); err != nil { + return nil, err + } + if len(seed.Files) == 0 { + return nil, fmt.Errorf("CAS requires at least one file") + } + var payload CASPayload + if len(seed.Files) == 1 { + entry, err := buildCASFileEntry(seed.Files[0], seed.PieceSize) + if err != nil { + return nil, err + } + payload = CASPayload{ + Name: entry.Name, Size: entry.Size, MD5: entry.MD5, + SliceMD5: entry.SliceMD5, CreateTime: entry.CreateTime, + } + } else { + entries := make([]CASFileEntry, 0, len(seed.Files)) + var totalSize int64 + for _, file := range seed.Files { + entry, err := buildCASFileEntry(file, seed.PieceSize) + if err != nil { + return nil, err + } + entries = append(entries, entry) + totalSize += entry.Size + } + payload = CASPayload{Name: seed.Name, Size: totalSize, Files: entries} } content, err := json.Marshal(payload) if err != nil { @@ -864,6 +904,31 @@ func DecodeCAS(data []byte, limits ParseLimits) (*Seed, error) { if err = json.Unmarshal(data, &payload); err != nil { return nil, fmt.Errorf("decode CAS: %w", err) } + seed := NewSeed(payload.Name, "OpenList CAS", DefaultPieceSize) + if len(payload.Files) > 0 { + // Multi-file extension. + if len(payload.Files) > limits.MaxFiles { + return nil, fmt.Errorf("CAS seed exceeds %d files", limits.MaxFiles) + } + for _, entry := range payload.Files { + if entry.Name == "" || entry.Size < 0 || !validHexHash(entry.MD5, 32) { + return nil, fmt.Errorf("invalid CAS file entry") + } + sliceMD5 := entry.SliceMD5 + if sliceMD5 == "" { + sliceMD5 = entry.MD5 + } + if !validHexHash(sliceMD5, 32) { + return nil, fmt.Errorf("invalid CAS sliceMd5") + } + seed.Files = append(seed.Files, SeedFile{ + Path: entry.Name, Size: entry.Size, CASCreateTime: entry.CreateTime, + CASSliceMD5: strings.ToLower(sliceMD5), + Hashes: SeedHashes{MD5: strings.ToLower(entry.MD5)}, + }) + } + return seed, ValidateSeed(seed, limits) + } if payload.Name == "" || payload.Size < 0 || !validHexHash(payload.MD5, 32) { return nil, fmt.Errorf("invalid CAS payload") } @@ -873,7 +938,6 @@ func DecodeCAS(data []byte, limits ParseLimits) (*Seed, error) { if !validHexHash(payload.SliceMD5, 32) { return nil, fmt.Errorf("invalid CAS sliceMd5") } - seed := NewSeed(payload.Name, "OpenList CAS", DefaultPieceSize) seed.Files = []SeedFile{{ Path: payload.Name, Size: payload.Size, CASCreateTime: payload.CreateTime, CASSliceMD5: strings.ToLower(payload.SliceMD5), @@ -961,10 +1025,7 @@ func DiagnoseConversion(seed *Seed, format string) []string { } } case "cas": - if len(seed.Files) != 1 { - diagnostics = append(diagnostics, "CAS supports exactly one file") - } else { - file := seed.Files[0] + for _, file := range seed.Files { if file.Hashes.MD5 == "" { diagnostics = append(diagnostics, file.Path+": missing whole-file MD5") }