Files
jukebox_maker/internal/copier/copier.go
Michael Chus 29f3ad9576 Add jukebox_maker web app v1.0
Go web application for filling USB drives with media files.
Runs in Docker on Unraid with /media, /mnt/usb, /config volumes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:33:43 +03:00

281 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package copier
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sync"
"jukebox_maker/internal/config"
"jukebox_maker/internal/db"
"jukebox_maker/internal/disk"
"jukebox_maker/internal/task"
)
type Options struct {
DiskID string
MountPath string
MediaPath string
EnabledSources []string
ReserveFreeGB float64
OverwriteMode config.OverwriteMode
FileSelectMode config.FileSelectMode
}
type Copier struct {
tasks *task.Store
mu sync.Mutex
cancel context.CancelFunc
dbMu sync.RWMutex
db *db.DB
}
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
c.dbMu.Unlock()
}
func (c *Copier) getDB() *db.DB {
c.dbMu.RLock()
defer c.dbMu.RUnlock()
return c.db
}
func (c *Copier) Start(ctx context.Context, opts Options) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if _, active := c.tasks.ActiveTask(); active {
return "", errors.New("copy already running")
}
database := c.getDB()
if database == nil {
return "", errors.New("no disk database available")
}
t := c.tasks.Create("copy")
copyCtx, cancel := context.WithCancel(ctx)
c.cancel = cancel
go c.run(copyCtx, t.ID, opts, database)
return t.ID, nil
}
func (c *Copier) Cancel() {
c.mu.Lock()
defer c.mu.Unlock()
if c.cancel != nil {
c.cancel()
}
}
func (c *Copier) run(ctx context.Context, taskID string, opts Options, database *db.DB) {
setStatus := func(s task.Status, msg string, prog int) {
c.tasks.Update(taskID, func(t *task.Task) {
t.Status = s
t.Message = msg
t.Progress = prog
})
}
fail := func(err error) {
c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusFailed
t.Error = err.Error()
})
}
setStatus(task.StatusRunning, "Подготовка…", 0)
if opts.OverwriteMode == config.OverwriteDelete {
setStatus(task.StatusRunning, "Удаление данных с диска…", 0)
if err := deleteOurData(opts.MountPath); err != nil {
fail(err)
return
}
}
var copiedPaths map[string]struct{}
if opts.FileSelectMode == config.SelectNew {
setStatus(task.StatusRunning, "Загрузка истории…", 0)
var err error
copiedPaths, err = database.CopiedPaths(opts.DiskID)
if err != nil {
fail(err)
return
}
}
setStatus(task.StatusRunning, "Сканирование источников…", 0)
files, err := buildFileList(opts.MediaPath, opts.EnabledSources, copiedPaths)
if err != nil {
fail(err)
return
}
if len(files) == 0 {
setStatus(task.StatusSuccess, "Нет новых файлов для копирования.", 100)
return
}
_, free, err := disk.DiskUsage(opts.MountPath)
if err != nil {
fail(err)
return
}
reserveBytes := int64(opts.ReserveFreeGB * 1e9)
available := free - reserveBytes
if available <= 0 {
setStatus(task.StatusSuccess, "Недостаточно свободного места на диске.", 100)
return
}
total := len(files)
copied := 0
for i, f := range files {
select {
case <-ctx.Done():
c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusCanceled
t.Message = "Отменено"
})
return
default:
}
if f.size > available {
continue
}
msg := fmt.Sprintf("Копирование %s (%d/%d)", filepath.Base(f.srcAbs), i+1, total)
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 {
if errors.Is(err, context.Canceled) {
c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusCanceled
t.Message = "Отменено"
})
return
}
continue
}
available -= f.size
copied++
_ = database.RecordCopy(db.CopyRecord{
DiskID: opts.DiskID,
SourcePath: f.relPath,
FileSize: f.size,
})
}
setStatus(task.StatusSuccess, fmt.Sprintf("Готово. Скопировано файлов: %d.", copied), 100)
}
type fileEntry struct {
srcAbs string
relPath string
size int64
}
func buildFileList(mediaPath string, sources []string, skip map[string]struct{}) ([]fileEntry, error) {
var result []fileEntry
for _, src := range sources {
dir := filepath.Join(mediaPath, src)
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
rel, _ := filepath.Rel(mediaPath, path)
if _, skipped := skip[rel]; skipped {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
result = append(result, fileEntry{srcAbs: path, relPath: rel, size: info.Size()})
return nil
})
if err != nil {
return nil, err
}
}
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 {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
in, err := os.Open(src)
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)
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
}
}
out.Close()
return os.Rename(tmp, dst)
}