feat(seed): multi-file CAS and derive seed name

CAS now supports multiple files via a files array while keeping the legacy five-field single-file payload byte-compatible. Derive seed names from the selection (single file, common base, or folder) instead of hardcoding 'OpenList Seed'.
This commit is contained in:
PIKACHUIM
2026-09-09 15:40:30 +08:00
parent ac1192a8d9
commit d684e45d63
2 changed files with 137 additions and 45 deletions
+56 -25
View File
@@ -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)
+81 -20
View File
@@ -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")
}