Add configurable shuffle depth for copy order

ShuffleDepth in DiskProfile controls how files are selected:
  -1 = no shuffle (preserve source order)
   0 = all files in random order
   N = group files by folder at depth N from /media, shuffle groups,
       copy entire group before moving to next

Exposed in disk profile UI as a select with level descriptions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 21:30:55 +03:00
parent 0a17d11bd1
commit 2bad23da3a
5 changed files with 63 additions and 2 deletions

View File

@@ -26,6 +26,7 @@ func (s *Server) copyOptions(cfg *config.Config, diskInfo disk.DiskInfo, overwri
opts.ReserveFreeGB = p.ReserveFreeGB
opts.FileSelectMode = config.FileSelectMode(p.FileSelectMode)
opts.Transcode = p.Transcode
opts.ShuffleDepth = p.ShuffleDepth
} else {
opts.DestFolder = cfg.DestFolder
opts.ReserveFreeGB = cfg.ReserveFreeGB

View File

@@ -33,6 +33,8 @@ type Options struct {
OverwriteMode config.OverwriteMode
FileSelectMode config.FileSelectMode
Transcode *disk.TranscodeProfile // nil = не транскодировать
// ShuffleDepth: -1=выкл, 0=файлы вразнобой, 1+=группировка по папке на глубине N
ShuffleDepth int
}
type Copier struct {
@@ -254,8 +256,7 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
return
}
// случайный порядок — выбираем что копировать до начала копирования
rand.Shuffle(len(files), func(i, j int) { files[i], files[j] = files[j], files[i] })
files = applyShuffleDepth(files, opts.ShuffleDepth)
_, free, err := disk.DiskUsage(opts.MountPath)
if err != nil {
@@ -723,3 +724,52 @@ func copyFile(ctx context.Context, src, dst string) error {
}
return nil
}
// applyShuffleDepth упорядочивает файлы по заданной глубине шафлера.
// depth < 0 → оригинальный порядок (без шафла)
// depth == 0 → все файлы перемешиваются случайно
// depth >= 1 → файлы группируются по папке на уровне depth от корня /media,
// группы перемешиваются, внутри каждой группы порядок сохраняется.
func applyShuffleDepth(files []fileEntry, depth int) []fileEntry {
if depth < 0 || len(files) == 0 {
return files
}
if depth == 0 {
rand.Shuffle(len(files), func(i, j int) { files[i], files[j] = files[j], files[i] })
return files
}
type group struct {
key string
files []fileEntry
}
groupMap := make(map[string]*group, 64)
var order []string
for _, f := range files {
key := folderKeyAtDepth(f.relPath, depth)
if _, ok := groupMap[key]; !ok {
groupMap[key] = &group{key: key}
order = append(order, key)
}
groupMap[key].files = append(groupMap[key].files, f)
}
rand.Shuffle(len(order), func(i, j int) { order[i], order[j] = order[j], order[i] })
result := make([]fileEntry, 0, len(files))
for _, key := range order {
result = append(result, groupMap[key].files...)
}
return result
}
// folderKeyAtDepth возвращает путь к папке глубины depth из relPath.
// relPath вида "anime/Naruto/Season1/ep01.mkv", depth=2 → "anime/Naruto"
func folderKeyAtDepth(relPath string, depth int) string {
relPath = filepath.ToSlash(relPath)
parts := strings.Split(relPath, "/")
maxDepth := len(parts) - 1 // последний элемент — имя файла
if depth >= maxDepth {
depth = maxDepth
}
return strings.Join(parts[:depth], "/")
}

View File

@@ -12,6 +12,8 @@ type DiskProfile struct {
FileSelectMode string `json:"file_select_mode"`
ReserveFreeGB float64 `json:"reserve_free_gb"`
AutoCopy bool `json:"auto_copy"`
// ShuffleDepth: -1=выкл, 0=файлы вразнобой, 1+=папки на глубине N от корня /media
ShuffleDepth int `json:"shuffle_depth"`
// nil = не транскодировать видео
Transcode *TranscodeProfile `json:"transcode,omitempty"`
@@ -66,5 +68,6 @@ func DefaultProfile() *DiskProfile {
FileSelectMode: "new",
ReserveFreeGB: 2.0,
AutoCopy: false,
ShuffleDepth: -1,
}
}