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:
@@ -230,6 +230,7 @@ func triggerAutoCopy(cp *copier.Copier, cfg *config.Config, info disk.DiskInfo)
|
|||||||
opts.ReserveFreeGB = p.ReserveFreeGB
|
opts.ReserveFreeGB = p.ReserveFreeGB
|
||||||
opts.FileSelectMode = config.FileSelectMode(p.FileSelectMode)
|
opts.FileSelectMode = config.FileSelectMode(p.FileSelectMode)
|
||||||
opts.Transcode = p.Transcode
|
opts.Transcode = p.Transcode
|
||||||
|
opts.ShuffleDepth = p.ShuffleDepth
|
||||||
if p.OverwriteMode != "" {
|
if p.OverwriteMode != "" {
|
||||||
opts.OverwriteMode = config.OverwriteMode(p.OverwriteMode)
|
opts.OverwriteMode = config.OverwriteMode(p.OverwriteMode)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ func (s *Server) copyOptions(cfg *config.Config, diskInfo disk.DiskInfo, overwri
|
|||||||
opts.ReserveFreeGB = p.ReserveFreeGB
|
opts.ReserveFreeGB = p.ReserveFreeGB
|
||||||
opts.FileSelectMode = config.FileSelectMode(p.FileSelectMode)
|
opts.FileSelectMode = config.FileSelectMode(p.FileSelectMode)
|
||||||
opts.Transcode = p.Transcode
|
opts.Transcode = p.Transcode
|
||||||
|
opts.ShuffleDepth = p.ShuffleDepth
|
||||||
} else {
|
} else {
|
||||||
opts.DestFolder = cfg.DestFolder
|
opts.DestFolder = cfg.DestFolder
|
||||||
opts.ReserveFreeGB = cfg.ReserveFreeGB
|
opts.ReserveFreeGB = cfg.ReserveFreeGB
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ type Options struct {
|
|||||||
OverwriteMode config.OverwriteMode
|
OverwriteMode config.OverwriteMode
|
||||||
FileSelectMode config.FileSelectMode
|
FileSelectMode config.FileSelectMode
|
||||||
Transcode *disk.TranscodeProfile // nil = не транскодировать
|
Transcode *disk.TranscodeProfile // nil = не транскодировать
|
||||||
|
// ShuffleDepth: -1=выкл, 0=файлы вразнобой, 1+=группировка по папке на глубине N
|
||||||
|
ShuffleDepth int
|
||||||
}
|
}
|
||||||
|
|
||||||
type Copier struct {
|
type Copier struct {
|
||||||
@@ -254,8 +256,7 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// случайный порядок — выбираем что копировать до начала копирования
|
files = applyShuffleDepth(files, opts.ShuffleDepth)
|
||||||
rand.Shuffle(len(files), func(i, j int) { files[i], files[j] = files[j], files[i] })
|
|
||||||
|
|
||||||
_, free, err := disk.DiskUsage(opts.MountPath)
|
_, free, err := disk.DiskUsage(opts.MountPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -723,3 +724,52 @@ func copyFile(ctx context.Context, src, dst string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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], "/")
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ type DiskProfile struct {
|
|||||||
FileSelectMode string `json:"file_select_mode"`
|
FileSelectMode string `json:"file_select_mode"`
|
||||||
ReserveFreeGB float64 `json:"reserve_free_gb"`
|
ReserveFreeGB float64 `json:"reserve_free_gb"`
|
||||||
AutoCopy bool `json:"auto_copy"`
|
AutoCopy bool `json:"auto_copy"`
|
||||||
|
// ShuffleDepth: -1=выкл, 0=файлы вразнобой, 1+=папки на глубине N от корня /media
|
||||||
|
ShuffleDepth int `json:"shuffle_depth"`
|
||||||
|
|
||||||
// nil = не транскодировать видео
|
// nil = не транскодировать видео
|
||||||
Transcode *TranscodeProfile `json:"transcode,omitempty"`
|
Transcode *TranscodeProfile `json:"transcode,omitempty"`
|
||||||
@@ -66,5 +68,6 @@ func DefaultProfile() *DiskProfile {
|
|||||||
FileSelectMode: "new",
|
FileSelectMode: "new",
|
||||||
ReserveFreeGB: 2.0,
|
ReserveFreeGB: 2.0,
|
||||||
AutoCopy: false,
|
AutoCopy: false,
|
||||||
|
ShuffleDepth: -1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,6 +209,11 @@ function renderProfile(disk) {
|
|||||||
<label>Выбор файлов</label>
|
<label>Выбор файлов</label>
|
||||||
${sel('file_select_mode', p.file_select_mode || 'new', [['new','Только новые'],['all','Все подходящие']])}
|
${sel('file_select_mode', p.file_select_mode || 'new', [['new','Только новые'],['all','Все подходящие']])}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Порядок копирования</label>
|
||||||
|
${sel('shuffle_depth', String(p.shuffle_depth ?? -1), [['-1','По порядку (без перемешивания)'],['0','Случайный порядок файлов'],['1','Случайная папка 1-го уровня (жанр)'],['2','Случайная папка 2-го уровня (сериал)'],['3','Случайная папка 3-го уровня (сезон)']])}
|
||||||
|
<span class="form-hint">Уровень задаёт глубину вложения: все файлы выбранной папки копируются целиком.</span>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Резерв свободного места (ГБ)</label>
|
<label>Резерв свободного места (ГБ)</label>
|
||||||
<input class="form-input" type="number" id="prof_reserve_free_gb" value="${p.reserve_free_gb ?? 2}" min="0" step="0.5">
|
<input class="form-input" type="number" id="prof_reserve_free_gb" value="${p.reserve_free_gb ?? 2}" min="0" step="0.5">
|
||||||
@@ -245,6 +250,7 @@ async function saveProfile(mountPath) {
|
|||||||
file_select_mode: g('prof_file_select_mode')?.value || 'new',
|
file_select_mode: g('prof_file_select_mode')?.value || 'new',
|
||||||
reserve_free_gb: parseFloat(g('prof_reserve_free_gb')?.value || '2') || 0,
|
reserve_free_gb: parseFloat(g('prof_reserve_free_gb')?.value || '2') || 0,
|
||||||
auto_copy: g('prof_auto_copy')?.checked || false,
|
auto_copy: g('prof_auto_copy')?.checked || false,
|
||||||
|
shuffle_depth: parseInt(g('prof_shuffle_depth')?.value ?? '-1', 10),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (transcodeEnabled) {
|
if (transcodeEnabled) {
|
||||||
|
|||||||
Reference in New Issue
Block a user