copier: rsync с возобновлением, папка назначения, права 777

- Заменить ручное копирование на rsync --partial --append-verify
- Структура на диске: <mount>/<dest_folder>/<rel path from /media>
- dest_folder настраивается (default: media)
- Права на диске: --no-perms --chmod=ugo=rwx
- rsync добавлен в Dockerfile
- Режим "удалить": удаляет только dest_folder, а не весь диск

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-23 21:56:26 +03:00
parent f2a7505378
commit 8f36d4e824
6 changed files with 47 additions and 62 deletions

View File

@@ -10,7 +10,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \
FROM alpine:3.19 FROM alpine:3.19
RUN apk add --no-cache tzdata ca-certificates RUN apk add --no-cache tzdata ca-certificates rsync
WORKDIR /app WORKDIR /app
COPY --from=builder /out/jukebox . COPY --from=builder /out/jukebox .

View File

@@ -137,6 +137,7 @@ func triggerAutoCopy(cp *copier.Copier, cfg *config.Config, info disk.DiskInfo,
DiskID: info.DiskID, DiskID: info.DiskID,
MountPath: info.MountPath, MountPath: info.MountPath,
MediaPath: mediaPath, MediaPath: mediaPath,
DestFolder: cfg.DestFolder,
EnabledSources: sources, EnabledSources: sources,
ReserveFreeGB: cfg.ReserveFreeGB, ReserveFreeGB: cfg.ReserveFreeGB,
OverwriteMode: cfg.OverwriteMode, OverwriteMode: cfg.OverwriteMode,

View File

@@ -31,6 +31,7 @@ func (s *Server) handleCopyStart(w http.ResponseWriter, r *http.Request) {
DiskID: diskInfo.DiskID, DiskID: diskInfo.DiskID,
MountPath: diskInfo.MountPath, MountPath: diskInfo.MountPath,
MediaPath: s.deps.MediaPath, MediaPath: s.deps.MediaPath,
DestFolder: cfg.DestFolder,
EnabledSources: enabledSources, EnabledSources: enabledSources,
ReserveFreeGB: cfg.ReserveFreeGB, ReserveFreeGB: cfg.ReserveFreeGB,
OverwriteMode: cfg.OverwriteMode, OverwriteMode: cfg.OverwriteMode,

View File

@@ -25,6 +25,7 @@ type SourceFolder struct {
type Config struct { type Config struct {
ReserveFreeGB float64 `json:"reserve_free_gb"` ReserveFreeGB float64 `json:"reserve_free_gb"`
DestFolder string `json:"dest_folder"`
Sources []SourceFolder `json:"sources"` Sources []SourceFolder `json:"sources"`
OverwriteMode OverwriteMode `json:"overwrite_mode"` OverwriteMode OverwriteMode `json:"overwrite_mode"`
FileSelectMode FileSelectMode `json:"file_select_mode"` FileSelectMode FileSelectMode `json:"file_select_mode"`
@@ -34,6 +35,7 @@ type Config struct {
func defaults() Config { func defaults() Config {
return Config{ return Config{
ReserveFreeGB: 2.0, ReserveFreeGB: 2.0,
DestFolder: "media",
OverwriteMode: OverwriteSkip, OverwriteMode: OverwriteSkip,
FileSelectMode: SelectNew, FileSelectMode: SelectNew,
AutoCopy: false, AutoCopy: false,

View File

@@ -4,8 +4,8 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"sync" "sync"
@@ -19,6 +19,7 @@ type Options struct {
DiskID string DiskID string
MountPath string MountPath string
MediaPath string MediaPath string
DestFolder string // subfolder on disk, default "media"
EnabledSources []string EnabledSources []string
ReserveFreeGB float64 ReserveFreeGB float64
OverwriteMode config.OverwriteMode OverwriteMode config.OverwriteMode
@@ -39,7 +40,6 @@ func New(tasks *task.Store) *Copier {
return &Copier{tasks: tasks} return &Copier{tasks: tasks}
} }
// SetDB replaces the active disk database (called when a disk connects or disconnects).
func (c *Copier) SetDB(d *db.DB) { func (c *Copier) SetDB(d *db.DB) {
c.dbMu.Lock() c.dbMu.Lock()
c.db = d 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") return "", errors.New("no disk database available")
} }
if opts.DestFolder == "" {
opts.DestFolder = "media"
}
t := c.tasks.Create("copy") t := c.tasks.Create("copy")
copyCtx, cancel := context.WithCancel(ctx) copyCtx, cancel := context.WithCancel(ctx)
c.cancel = cancel c.cancel = cancel
@@ -98,9 +102,11 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
setStatus(task.StatusRunning, "Подготовка…", 0) setStatus(task.StatusRunning, "Подготовка…", 0)
destRoot := filepath.Join(opts.MountPath, opts.DestFolder)
if opts.OverwriteMode == config.OverwriteDelete { if opts.OverwriteMode == config.OverwriteDelete {
setStatus(task.StatusRunning, "Удаление данных с диска…", 0) setStatus(task.StatusRunning, "Удаление данных с диска…", 0)
if err := deleteOurData(opts.MountPath); err != nil { if err := os.RemoveAll(destRoot); err != nil {
fail(err) fail(err)
return 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) prog := int(float64(i+1) / float64(total) * 100)
setStatus(task.StatusRunning, msg, prog) setStatus(task.StatusRunning, msg, prog)
dstAbs := filepath.Join(opts.MountPath, f.relPath) // destination mirrors source structure under destRoot
if err := copyFile(ctx, f.srcAbs, dstAbs); err != nil { dstAbs := filepath.Join(destRoot, f.relPath)
if err := rsyncFile(ctx, f.srcAbs, dstAbs); err != nil {
if errors.Is(err, context.Canceled) { if errors.Is(err, context.Canceled) {
c.tasks.Update(taskID, func(t *task.Task) { c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusCanceled t.Status = task.StatusCanceled
@@ -187,7 +195,7 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
type fileEntry struct { type fileEntry struct {
srcAbs string srcAbs string
relPath string relPath string // relative to /media
size int64 size int64
} }
@@ -217,64 +225,29 @@ func buildFileList(mediaPath string, sources []string, skip map[string]struct{})
return result, nil return result, nil
} }
func deleteOurData(mountPath string) error { // rsyncFile copies src to dst using rsync with resume support.
entries, err := os.ReadDir(mountPath) // --partial keeps partial files on interruption.
if err != nil { // --append-verify resumes partial transfers and verifies checksums.
return err func rsyncFile(ctx context.Context, src, dst string) error {
}
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 {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err 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 { if err != nil {
return err if ctx.Err() != nil {
}
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)
return ctx.Err() 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 nil
return os.Rename(tmp, dst)
} }

View File

@@ -30,13 +30,19 @@
<span class="form-hint">«Только новые» — пропускает файлы, уже скопированные на данный диск, даже если они были удалены с него (считаются просмотренными).</span> <span class="form-hint">«Только новые» — пропускает файлы, уже скопированные на данный диск, даже если они были удалены с него (считаются просмотренными).</span>
</div> </div>
<div class="form-group">
<label class="form-label" for="destFolder">Папка назначения на диске</label>
<input class="form-input" type="text" id="destFolder" placeholder="media" style="width:200px">
<span class="form-hint">Подпапка на диске куда копировать файлы. Структура источника воспроизводится внутри неё. По умолчанию: <code>media</code>.</span>
</div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="overwriteMode">Режим записи</label> <label class="form-label" for="overwriteMode">Режим записи</label>
<select class="form-select" id="overwriteMode" style="width:auto;max-width:420px"> <select class="form-select" id="overwriteMode" style="width:auto;max-width:420px">
<option value="skip">Пропустить существующие файлы</option> <option value="skip">Пропустить существующие файлы</option>
<option value="delete">Удалить наши данные с диска и перезаписать заново</option> <option value="delete">Удалить папку назначения и перезаписать заново</option>
</select> </select>
<span class="form-hint">«Удалить и перезаписать» — удаляет с диска всё кроме папки .jukebox, затем копирует заново.</span> <span class="form-hint">«Удалить и перезаписать» — удаляет папку назначения на диске, затем копирует заново.</span>
</div> </div>
</div> </div>
@@ -98,6 +104,7 @@ async function loadSettings() {
if (!r.ok) return; if (!r.ok) return;
const cfg = await r.json(); const cfg = await r.json();
document.getElementById('reserveGB').value = cfg.reserve_free_gb ?? 2; 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('fileSelectMode').value = cfg.file_select_mode || 'new';
document.getElementById('overwriteMode').value = cfg.overwrite_mode || 'skip'; document.getElementById('overwriteMode').value = cfg.overwrite_mode || 'skip';
document.getElementById('autoCopy').checked = !!cfg.auto_copy; document.getElementById('autoCopy').checked = !!cfg.auto_copy;
@@ -116,6 +123,7 @@ async function saveSettings(e) {
}); });
const body = { const body = {
reserve_free_gb: parseFloat(document.getElementById('reserveGB').value) || 2, reserve_free_gb: parseFloat(document.getElementById('reserveGB').value) || 2,
dest_folder: document.getElementById('destFolder').value.trim() || 'media',
file_select_mode: document.getElementById('fileSelectMode').value, file_select_mode: document.getElementById('fileSelectMode').value,
overwrite_mode: document.getElementById('overwriteMode').value, overwrite_mode: document.getElementById('overwriteMode').value,
auto_copy: document.getElementById('autoCopy').checked, auto_copy: document.getElementById('autoCopy').checked,