ShuffleDepth in DiskProfile controls how files are selected:
-1 = no shuffle (preserve source order)
0 = all files in random order
N = group files by folder at depth N from /media, shuffle groups,
copy entire group before moving to next
Exposed in disk profile UI as a select with level descriptions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
250 lines
6.1 KiB
Go
250 lines
6.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"jukebox_maker/internal/api"
|
|
"jukebox_maker/internal/config"
|
|
"jukebox_maker/internal/copier"
|
|
"jukebox_maker/internal/db"
|
|
"jukebox_maker/internal/disk"
|
|
"jukebox_maker/internal/task"
|
|
"jukebox_maker/internal/watcher"
|
|
)
|
|
|
|
var Version = "dev"
|
|
|
|
func main() {
|
|
configPath := flag.String("config", "/config/config.json", "path to config file")
|
|
addr := flag.String("addr", ":8080", "HTTP listen address")
|
|
mediaPath := flag.String("media", "/media", "path to media source directory")
|
|
mountPath := flag.String("mount", "/mnt/usb", "path to USB mount point")
|
|
flag.Parse()
|
|
|
|
cfg, err := config.Load(*configPath)
|
|
if err != nil {
|
|
log.Fatalf("load config: %v", err)
|
|
}
|
|
if cfg.MediaPath == "" {
|
|
cfg.MediaPath = config.NormalizeMediaPath(*mediaPath)
|
|
}
|
|
|
|
taskStore := task.NewStore()
|
|
cp := copier.New(taskStore)
|
|
|
|
activeDBs := make(map[string]*db.DB)
|
|
mountToDiskID := make(map[string]string)
|
|
|
|
resumeDiskTask := func(info disk.DiskInfo, database *db.DB) {
|
|
rec, ok, err := database.ActiveTask()
|
|
if err != nil {
|
|
log.Printf("load active task for %s: %v", info.DiskID, err)
|
|
return
|
|
}
|
|
if !ok || rec.Task.Type != "copy" {
|
|
return
|
|
}
|
|
|
|
var opts copier.Options
|
|
if err := json.Unmarshal(rec.Payload, &opts); err != nil {
|
|
log.Printf("decode task payload for %s: %v", info.DiskID, err)
|
|
return
|
|
}
|
|
opts.DiskID = info.DiskID
|
|
opts.MountPath = info.MountPath
|
|
if rec.Task.Phase != task.PhaseQueued && rec.Task.Phase != task.PhasePreparing && rec.Task.Phase != task.PhaseReplacing && opts.OverwriteMode == config.OverwriteDelete {
|
|
opts.OverwriteMode = config.OverwriteSkip
|
|
}
|
|
|
|
taskStore.Upsert(rec.Task)
|
|
if err := cp.Resume(context.Background(), rec.Task.ID, opts); err != nil {
|
|
log.Printf("resume task %s for %s: %v", rec.Task.ID, info.DiskID, err)
|
|
}
|
|
}
|
|
|
|
openDiskDB := func(info disk.DiskInfo) {
|
|
if info.DiskID == "" {
|
|
return
|
|
}
|
|
|
|
if prevDiskID, ok := mountToDiskID[info.MountPath]; ok && prevDiskID != info.DiskID {
|
|
if prevDB := activeDBs[prevDiskID]; prevDB != nil {
|
|
prevDB.Close()
|
|
delete(activeDBs, prevDiskID)
|
|
cp.SetDB(prevDiskID, nil)
|
|
}
|
|
}
|
|
mountToDiskID[info.MountPath] = info.DiskID
|
|
|
|
if _, ok := activeDBs[info.DiskID]; ok {
|
|
return
|
|
}
|
|
|
|
d, err := db.Open(disk.DBPath(info.MountPath))
|
|
if err != nil {
|
|
log.Printf("open disk DB: %v", err)
|
|
return
|
|
}
|
|
activeDBs[info.DiskID] = d
|
|
cp.SetDB(info.DiskID, d)
|
|
log.Printf("disk DB opened for %s", info.DiskID)
|
|
resumeDiskTask(info, d)
|
|
}
|
|
|
|
closeDiskDB := func(info disk.DiskInfo) {
|
|
diskID := info.DiskID
|
|
if diskID == "" {
|
|
diskID = mountToDiskID[info.MountPath]
|
|
}
|
|
if diskID == "" {
|
|
return
|
|
}
|
|
|
|
cp.Cancel(diskID)
|
|
cp.SetDB(diskID, nil)
|
|
|
|
if d := activeDBs[diskID]; d != nil {
|
|
d.Close()
|
|
delete(activeDBs, diskID)
|
|
log.Printf("disk DB closed for %s", diskID)
|
|
}
|
|
delete(mountToDiskID, info.MountPath)
|
|
}
|
|
|
|
watcherReady := false
|
|
w := watcher.New(*mountPath, 5*time.Second, func(ev watcher.DiskEvent) {
|
|
log.Printf("disk: %s %s -> %s", ev.Info.MountPath, ev.Prev.State, ev.Info.State)
|
|
switch ev.Info.State {
|
|
case disk.DiskKnown:
|
|
openDiskDB(ev.Info)
|
|
if watcherReady && ev.Prev.State != disk.DiskKnown {
|
|
triggerAutoCopy(cp, cfg, ev.Info)
|
|
}
|
|
case disk.DiskForeign:
|
|
closeDiskDB(ev.Prev)
|
|
case disk.DiskAbsent:
|
|
closeDiskDB(ev.Prev)
|
|
}
|
|
})
|
|
w.ProbeNow()
|
|
watcherReady = true
|
|
|
|
probeDisk := func(mountPath string) (disk.DiskInfo, error) {
|
|
mountPath = config.NormalizeMediaPath(mountPath)
|
|
if mountPath == "" {
|
|
return disk.DiskInfo{}, errors.New("mount_path is required")
|
|
}
|
|
info, err := disk.Probe(mountPath)
|
|
if err != nil {
|
|
return info, err
|
|
}
|
|
if info.State == disk.DiskKnown {
|
|
openDiskDB(info)
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
srv, err := api.New(api.Deps{
|
|
Config: cfg,
|
|
ConfigPath: *configPath,
|
|
Version: Version,
|
|
Watcher: w,
|
|
Copier: cp,
|
|
Tasks: taskStore,
|
|
ProbeDisk: probeDisk,
|
|
OnDiskInit: func(mountPath, diskID string) {
|
|
openDiskDB(disk.DiskInfo{
|
|
State: disk.DiskKnown,
|
|
DiskID: diskID,
|
|
MountPath: mountPath,
|
|
})
|
|
},
|
|
})
|
|
if err != nil {
|
|
log.Fatalf("init server: %v", err)
|
|
}
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
go w.Run(ctx)
|
|
|
|
httpSrv := &http.Server{Addr: *addr, Handler: srv}
|
|
go func() {
|
|
log.Printf("jukebox_maker listening on %s", *addr)
|
|
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
log.Fatalf("http: %v", err)
|
|
}
|
|
}()
|
|
|
|
<-ctx.Done()
|
|
log.Println("shutting down…")
|
|
shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
httpSrv.Shutdown(shutCtx)
|
|
for _, info := range w.ListDisks() {
|
|
closeDiskDB(info)
|
|
}
|
|
}
|
|
|
|
func triggerAutoCopy(cp *copier.Copier, cfg *config.Config, info disk.DiskInfo) {
|
|
// Используем AutoCopy из профиля диска, если он есть; иначе — из глобального config
|
|
autoCopy := cfg.AutoCopy
|
|
if info.Profile != nil {
|
|
autoCopy = info.Profile.AutoCopy
|
|
}
|
|
if !autoCopy {
|
|
return
|
|
}
|
|
|
|
hasEnabledSources := false
|
|
for _, s := range cfg.Sources {
|
|
if s.Enabled {
|
|
hasEnabledSources = true
|
|
break
|
|
}
|
|
}
|
|
if !hasEnabledSources {
|
|
return
|
|
}
|
|
|
|
opts := copier.Options{
|
|
DiskID: info.DiskID,
|
|
MountPath: info.MountPath,
|
|
MediaPath: cfg.MediaPath,
|
|
SourceRules: cfg.Sources,
|
|
AllowedExtensions: cfg.EffectiveAllowedExtensions(),
|
|
OverwriteMode: cfg.OverwriteMode,
|
|
}
|
|
if p := info.Profile; p != nil {
|
|
opts.DestFolder = p.DestFolder
|
|
opts.ReserveFreeGB = p.ReserveFreeGB
|
|
opts.FileSelectMode = config.FileSelectMode(p.FileSelectMode)
|
|
opts.Transcode = p.Transcode
|
|
opts.ShuffleDepth = p.ShuffleDepth
|
|
if p.OverwriteMode != "" {
|
|
opts.OverwriteMode = config.OverwriteMode(p.OverwriteMode)
|
|
}
|
|
} else {
|
|
opts.DestFolder = cfg.DestFolder
|
|
opts.ReserveFreeGB = cfg.ReserveFreeGB
|
|
opts.FileSelectMode = cfg.FileSelectMode
|
|
}
|
|
|
|
go func() {
|
|
_, err := cp.Start(context.Background(), opts)
|
|
if err != nil {
|
|
log.Printf("auto-copy: %v", err)
|
|
}
|
|
}()
|
|
}
|