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>
This commit is contained in:
280
internal/copier/copier.go
Normal file
280
internal/copier/copier.go
Normal file
@@ -0,0 +1,280 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user