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) } }() }