Improve disk UI and build performance
This commit is contained in:
+115
-14
@@ -8,6 +8,8 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -22,7 +24,7 @@ type Options struct {
|
||||
MountPath string
|
||||
MediaPath string
|
||||
DestFolder string // subfolder on disk, default "media"
|
||||
EnabledSources []string
|
||||
SourceRules []config.SourceFolder
|
||||
ReserveFreeGB float64
|
||||
OverwriteMode config.OverwriteMode
|
||||
FileSelectMode config.FileSelectMode
|
||||
@@ -62,6 +64,14 @@ func (c *Copier) getDB(diskID string) *db.DB {
|
||||
return c.dbs[diskID]
|
||||
}
|
||||
|
||||
func (c *Copier) LastCopiedAt(diskID string) (time.Time, bool, error) {
|
||||
database := c.getDB(diskID)
|
||||
if database == nil {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
return database.LastCopiedAt(diskID)
|
||||
}
|
||||
|
||||
func (c *Copier) Start(ctx context.Context, opts Options) (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -79,6 +89,15 @@ func (c *Copier) Start(ctx context.Context, opts Options) (string, error) {
|
||||
opts.DestFolder = "media"
|
||||
}
|
||||
|
||||
_, free, err := disk.DiskUsage(opts.MountPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
reserveBytes := int64(opts.ReserveFreeGB * 1e9)
|
||||
if free <= reserveBytes {
|
||||
return "", errors.New("free space is below reserve threshold")
|
||||
}
|
||||
|
||||
t := c.tasks.Create("copy", opts.DiskID)
|
||||
copyCtx, cancel := context.WithCancel(ctx)
|
||||
c.cancels[opts.DiskID] = cancel
|
||||
@@ -116,12 +135,12 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
|
||||
})
|
||||
}
|
||||
|
||||
setStatus(task.StatusRunning, "Подготовка…", 0)
|
||||
setStatus(task.StatusRunning, "Preparing...", 0)
|
||||
|
||||
destRoot := filepath.Join(opts.MountPath, opts.DestFolder)
|
||||
|
||||
if opts.OverwriteMode == config.OverwriteDelete {
|
||||
setStatus(task.StatusRunning, "Удаление данных с диска…", 0)
|
||||
setStatus(task.StatusRunning, "Replacing destination media...", 0)
|
||||
if err := os.RemoveAll(destRoot); err != nil {
|
||||
fail(err)
|
||||
return
|
||||
@@ -130,7 +149,7 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
|
||||
|
||||
var copiedPaths map[string]struct{}
|
||||
if opts.FileSelectMode == config.SelectNew {
|
||||
setStatus(task.StatusRunning, "Загрузка истории…", 0)
|
||||
setStatus(task.StatusRunning, "Loading copy history...", 0)
|
||||
var err error
|
||||
copiedPaths, err = database.CopiedPaths(opts.DiskID)
|
||||
if err != nil {
|
||||
@@ -139,14 +158,14 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
|
||||
}
|
||||
}
|
||||
|
||||
setStatus(task.StatusRunning, "Сканирование источников…", 0)
|
||||
files, err := buildFileList(opts.MediaPath, opts.EnabledSources, copiedPaths)
|
||||
setStatus(task.StatusRunning, "Scanning sources...", 0)
|
||||
files, err := buildFileList(opts.MediaPath, opts.SourceRules, copiedPaths)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
if len(files) == 0 {
|
||||
setStatus(task.StatusSuccess, "Нет новых файлов для копирования.", 100)
|
||||
setStatus(task.StatusSuccess, "No files to copy.", 100)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -161,7 +180,7 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
|
||||
reserveBytes := int64(opts.ReserveFreeGB * 1e9)
|
||||
available := free - reserveBytes
|
||||
if available <= 0 {
|
||||
setStatus(task.StatusSuccess, "Недостаточно свободного места на диске.", 100)
|
||||
setStatus(task.StatusFailed, "Free space is below the reserved threshold.", 100)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -181,7 +200,7 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
|
||||
case <-ctx.Done():
|
||||
c.tasks.Update(taskID, func(t *task.Task) {
|
||||
t.Status = task.StatusCanceled
|
||||
t.Message = "Отменено"
|
||||
t.Message = "Canceled"
|
||||
t.SpeedBPS = 0
|
||||
t.ETASec = 0
|
||||
})
|
||||
@@ -204,7 +223,7 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
|
||||
}
|
||||
|
||||
prog := int(float64(doneBytes) / float64(totalBytes) * 100)
|
||||
msg := fmt.Sprintf("Копирование %s (%d/%d)", filepath.Base(f.srcAbs), i+1, total)
|
||||
msg := fmt.Sprintf("Copying %s (%d/%d)", filepath.Base(f.srcAbs), i+1, total)
|
||||
|
||||
c.tasks.Update(taskID, func(t *task.Task) {
|
||||
t.Status = task.StatusRunning
|
||||
@@ -219,7 +238,7 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
|
||||
if errors.Is(err, context.Canceled) {
|
||||
c.tasks.Update(taskID, func(t *task.Task) {
|
||||
t.Status = task.StatusCanceled
|
||||
t.Message = "Отменено"
|
||||
t.Message = "Canceled"
|
||||
t.SpeedBPS = 0
|
||||
t.ETASec = 0
|
||||
})
|
||||
@@ -238,7 +257,7 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
|
||||
})
|
||||
}
|
||||
|
||||
setStatus(task.StatusSuccess, fmt.Sprintf("Готово. Скопировано файлов: %d.", copied), 100)
|
||||
setStatus(task.StatusSuccess, fmt.Sprintf("Done. Copied %d files.", copied), 100)
|
||||
}
|
||||
|
||||
type fileEntry struct {
|
||||
@@ -247,15 +266,35 @@ type fileEntry struct {
|
||||
size int64
|
||||
}
|
||||
|
||||
func buildFileList(mediaPath string, sources []string, skip map[string]struct{}) ([]fileEntry, error) {
|
||||
func buildFileList(mediaPath string, rules []config.SourceFolder, skip map[string]struct{}) ([]fileEntry, error) {
|
||||
roots, ruleMap := normalizeSourceRules(rules)
|
||||
|
||||
var result []fileEntry
|
||||
for _, src := range sources {
|
||||
for _, src := range roots {
|
||||
dir := filepath.Join(mediaPath, src)
|
||||
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if path == dir {
|
||||
return nil
|
||||
}
|
||||
rel, relErr := filepath.Rel(mediaPath, path)
|
||||
if relErr != nil {
|
||||
return nil
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if !isPathEnabled(rel, ruleMap) && !hasEnabledDescendant(rel, ruleMap) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(mediaPath, path)
|
||||
rel = filepath.ToSlash(rel)
|
||||
if !isPathEnabled(rel, ruleMap) {
|
||||
return nil
|
||||
}
|
||||
if _, skipped := skip[rel]; skipped {
|
||||
return nil
|
||||
}
|
||||
@@ -273,6 +312,68 @@ func buildFileList(mediaPath string, sources []string, skip map[string]struct{})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeSourceRules(rules []config.SourceFolder) ([]string, map[string]bool) {
|
||||
ruleMap := make(map[string]bool, len(rules))
|
||||
for _, rule := range rules {
|
||||
src := filepath.ToSlash(filepath.Clean(strings.TrimSpace(rule.Path)))
|
||||
src = strings.TrimPrefix(src, "./")
|
||||
src = strings.TrimPrefix(src, "/")
|
||||
if src == "" || src == "." {
|
||||
continue
|
||||
}
|
||||
if src == ".." || strings.HasPrefix(src, "../") {
|
||||
continue
|
||||
}
|
||||
ruleMap[src] = rule.Enabled
|
||||
}
|
||||
|
||||
var roots []string
|
||||
for src, enabled := range ruleMap {
|
||||
if !enabled || hasEnabledAncestor(src, ruleMap) {
|
||||
continue
|
||||
}
|
||||
roots = append(roots, src)
|
||||
}
|
||||
sort.Strings(roots)
|
||||
return roots, ruleMap
|
||||
}
|
||||
|
||||
func hasEnabledAncestor(path string, ruleMap map[string]bool) bool {
|
||||
for parent := parentSourcePath(path); parent != ""; parent = parentSourcePath(parent) {
|
||||
if ruleMap[parent] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasEnabledDescendant(path string, ruleMap map[string]bool) bool {
|
||||
prefix := path + "/"
|
||||
for other, enabled := range ruleMap {
|
||||
if enabled && strings.HasPrefix(other, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isPathEnabled(path string, ruleMap map[string]bool) bool {
|
||||
for current := path; current != ""; current = parentSourcePath(current) {
|
||||
if enabled, ok := ruleMap[current]; ok {
|
||||
return enabled
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parentSourcePath(path string) string {
|
||||
idx := strings.LastIndex(path, "/")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
return path[:idx]
|
||||
}
|
||||
|
||||
// rsyncFile copies src to dst using rsync with resume support.
|
||||
// --partial keeps partial files on interruption.
|
||||
// --append-verify resumes partial transfers and verifies checksums.
|
||||
|
||||
Reference in New Issue
Block a user