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:
2026-04-23 21:33:43 +03:00
parent eb3f84ea31
commit 29f3ad9576
24 changed files with 1901 additions and 0 deletions

280
internal/copier/copier.go Normal file
View 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)
}