From 8f36d4e824ace44311481496b7feef1191cbe4d9 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Thu, 23 Apr 2026 21:56:26 +0300 Subject: [PATCH] =?UTF-8?q?copier:=20rsync=20=D1=81=20=D0=B2=D0=BE=D0=B7?= =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=D0=BC,=20=D0=BF=D0=B0=D0=BF=D0=BA=D0=B0=20=D0=BD=D0=B0=D0=B7?= =?UTF-8?q?=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D1=8F,=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=B0=20777?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Заменить ручное копирование на rsync --partial --append-verify - Структура на диске: // - dest_folder настраивается (default: media) - Права на диске: --no-perms --chmod=ugo=rwx - rsync добавлен в Dockerfile - Режим "удалить": удаляет только dest_folder, а не весь диск Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 2 +- cmd/jukebox/main.go | 1 + internal/api/handlers_copy.go | 1 + internal/config/config.go | 2 + internal/copier/copier.go | 91 ++++++++++++----------------------- web/templates/settings.html | 12 ++++- 6 files changed, 47 insertions(+), 62 deletions(-) diff --git a/Dockerfile b/Dockerfile index 925e80d..bc1cf60 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \ FROM alpine:3.19 -RUN apk add --no-cache tzdata ca-certificates +RUN apk add --no-cache tzdata ca-certificates rsync WORKDIR /app COPY --from=builder /out/jukebox . diff --git a/cmd/jukebox/main.go b/cmd/jukebox/main.go index 24512c1..2abb258 100644 --- a/cmd/jukebox/main.go +++ b/cmd/jukebox/main.go @@ -137,6 +137,7 @@ func triggerAutoCopy(cp *copier.Copier, cfg *config.Config, info disk.DiskInfo, DiskID: info.DiskID, MountPath: info.MountPath, MediaPath: mediaPath, + DestFolder: cfg.DestFolder, EnabledSources: sources, ReserveFreeGB: cfg.ReserveFreeGB, OverwriteMode: cfg.OverwriteMode, diff --git a/internal/api/handlers_copy.go b/internal/api/handlers_copy.go index e42136c..7fd7c92 100644 --- a/internal/api/handlers_copy.go +++ b/internal/api/handlers_copy.go @@ -31,6 +31,7 @@ func (s *Server) handleCopyStart(w http.ResponseWriter, r *http.Request) { DiskID: diskInfo.DiskID, MountPath: diskInfo.MountPath, MediaPath: s.deps.MediaPath, + DestFolder: cfg.DestFolder, EnabledSources: enabledSources, ReserveFreeGB: cfg.ReserveFreeGB, OverwriteMode: cfg.OverwriteMode, diff --git a/internal/config/config.go b/internal/config/config.go index 18722fd..a97c6c2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,6 +25,7 @@ type SourceFolder struct { type Config struct { ReserveFreeGB float64 `json:"reserve_free_gb"` + DestFolder string `json:"dest_folder"` Sources []SourceFolder `json:"sources"` OverwriteMode OverwriteMode `json:"overwrite_mode"` FileSelectMode FileSelectMode `json:"file_select_mode"` @@ -34,6 +35,7 @@ type Config struct { func defaults() Config { return Config{ ReserveFreeGB: 2.0, + DestFolder: "media", OverwriteMode: OverwriteSkip, FileSelectMode: SelectNew, AutoCopy: false, diff --git a/internal/copier/copier.go b/internal/copier/copier.go index 587a550..8a3ad3d 100644 --- a/internal/copier/copier.go +++ b/internal/copier/copier.go @@ -4,8 +4,8 @@ import ( "context" "errors" "fmt" - "io" "os" + "os/exec" "path/filepath" "sync" @@ -19,6 +19,7 @@ type Options struct { DiskID string MountPath string MediaPath string + DestFolder string // subfolder on disk, default "media" EnabledSources []string ReserveFreeGB float64 OverwriteMode config.OverwriteMode @@ -39,7 +40,6 @@ func New(tasks *task.Store) *Copier { return &Copier{tasks: tasks} } -// SetDB replaces the active disk database (called when a disk connects or disconnects). func (c *Copier) SetDB(d *db.DB) { c.dbMu.Lock() c.db = d @@ -65,6 +65,10 @@ func (c *Copier) Start(ctx context.Context, opts Options) (string, error) { return "", errors.New("no disk database available") } + if opts.DestFolder == "" { + opts.DestFolder = "media" + } + t := c.tasks.Create("copy") copyCtx, cancel := context.WithCancel(ctx) c.cancel = cancel @@ -98,9 +102,11 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database setStatus(task.StatusRunning, "Подготовка…", 0) + destRoot := filepath.Join(opts.MountPath, opts.DestFolder) + if opts.OverwriteMode == config.OverwriteDelete { setStatus(task.StatusRunning, "Удаление данных с диска…", 0) - if err := deleteOurData(opts.MountPath); err != nil { + if err := os.RemoveAll(destRoot); err != nil { fail(err) return } @@ -161,8 +167,10 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database prog := int(float64(i+1) / float64(total) * 100) setStatus(task.StatusRunning, msg, prog) - dstAbs := filepath.Join(opts.MountPath, f.relPath) - if err := copyFile(ctx, f.srcAbs, dstAbs); err != nil { + // destination mirrors source structure under destRoot + dstAbs := filepath.Join(destRoot, f.relPath) + + if err := rsyncFile(ctx, f.srcAbs, dstAbs); err != nil { if errors.Is(err, context.Canceled) { c.tasks.Update(taskID, func(t *task.Task) { t.Status = task.StatusCanceled @@ -187,7 +195,7 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database type fileEntry struct { srcAbs string - relPath string + relPath string // relative to /media size int64 } @@ -217,64 +225,29 @@ func buildFileList(mediaPath string, sources []string, skip map[string]struct{}) return result, nil } -func deleteOurData(mountPath string) error { - entries, err := os.ReadDir(mountPath) - if err != nil { - return err - } - for _, e := range entries { - if e.Name() == ".jukebox" { - continue - } - if err := os.RemoveAll(filepath.Join(mountPath, e.Name())); err != nil { - return err - } - } - return nil -} - -func copyFile(ctx context.Context, src, dst string) error { +// rsyncFile copies src to dst using rsync with resume support. +// --partial keeps partial files on interruption. +// --append-verify resumes partial transfers and verifies checksums. +func rsyncFile(ctx context.Context, src, dst string) error { if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } - in, err := os.Open(src) + cmd := exec.CommandContext(ctx, "rsync", + "--partial", + "--append-verify", + "--times", + "--no-perms", + "--no-owner", + "--no-group", + "--chmod=ugo=rwx", + src, dst, + ) + out, err := cmd.CombinedOutput() if err != nil { - return err - } - defer in.Close() - - tmp := dst + ".juketmp" - out, err := os.Create(tmp) - if err != nil { - return err - } - - buf := make([]byte, 512*1024) - for { - select { - case <-ctx.Done(): - out.Close() - os.Remove(tmp) + if ctx.Err() != nil { return ctx.Err() - default: - } - n, readErr := in.Read(buf) - if n > 0 { - if _, werr := out.Write(buf[:n]); werr != nil { - out.Close() - os.Remove(tmp) - return werr - } - } - if errors.Is(readErr, io.EOF) { - break - } - if readErr != nil { - out.Close() - os.Remove(tmp) - return readErr } + return fmt.Errorf("rsync: %w: %s", err, out) } - out.Close() - return os.Rename(tmp, dst) + return nil } diff --git a/web/templates/settings.html b/web/templates/settings.html index ab70eb9..65c974b 100644 --- a/web/templates/settings.html +++ b/web/templates/settings.html @@ -30,13 +30,19 @@ «Только новые» — пропускает файлы, уже скопированные на данный диск, даже если они были удалены с него (считаются просмотренными). +
+ + + Подпапка на диске куда копировать файлы. Структура источника воспроизводится внутри неё. По умолчанию: media. +
+
- «Удалить и перезаписать» — удаляет с диска всё кроме папки .jukebox, затем копирует заново. + «Удалить и перезаписать» — удаляет папку назначения на диске, затем копирует заново.
@@ -98,6 +104,7 @@ async function loadSettings() { if (!r.ok) return; const cfg = await r.json(); document.getElementById('reserveGB').value = cfg.reserve_free_gb ?? 2; + document.getElementById('destFolder').value = cfg.dest_folder || 'media'; document.getElementById('fileSelectMode').value = cfg.file_select_mode || 'new'; document.getElementById('overwriteMode').value = cfg.overwrite_mode || 'skip'; document.getElementById('autoCopy').checked = !!cfg.auto_copy; @@ -116,6 +123,7 @@ async function saveSettings(e) { }); const body = { reserve_free_gb: parseFloat(document.getElementById('reserveGB').value) || 2, + dest_folder: document.getElementById('destFolder').value.trim() || 'media', file_select_mode: document.getElementById('fileSelectMode').value, overwrite_mode: document.getElementById('overwriteMode').value, auto_copy: document.getElementById('autoCopy').checked,