6 Commits

Author SHA1 Message Date
75c6b928ae Tighten disk safety checks 2026-04-24 07:18:17 +03:00
b8eabee393 Store unfinished tasks on disks 2026-04-24 07:10:26 +03:00
0afc1d761b Fix empty disk mount detection 2026-04-23 22:58:07 +03:00
e7917b41b5 Improve disk UI and build performance 2026-04-23 22:51:36 +03:00
31bac2b5d8 Add multi-disk copy workflow 2026-04-23 22:24:32 +03:00
5b3cb9e393 copier: прогресс по байтам, скорость и ETA
- Прогрессбар по скопированным байтам (doneBytes / totalBytes)
- SpeedBPS и ETASec добавлены в Task
- Dashboard показывает скорость (МБ/с) и ETA справа от прогрессбара

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 22:00:14 +03:00
18 changed files with 1479 additions and 317 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
.git
.gitignore
.DS_Store
bin
dist
tmp
.tmp

View File

@@ -1,11 +1,20 @@
FROM golang:1.25-alpine AS builder # syntax=docker/dockerfile:1.7
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
WORKDIR /src WORKDIR /src
ARG VERSION=dev
ARG TARGETOS
ARG TARGETARCH
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \ RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" \
-o /out/jukebox ./cmd/jukebox -o /out/jukebox ./cmd/jukebox
FROM alpine:3.19 FROM alpine:3.19

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"encoding/json"
"flag" "flag"
"log" "log"
"net/http" "net/http"
@@ -19,6 +20,8 @@ import (
"jukebox_maker/internal/watcher" "jukebox_maker/internal/watcher"
) )
var Version = "dev"
func main() { func main() {
configPath := flag.String("config", "/config/config.json", "path to config file") configPath := flag.String("config", "/config/config.json", "path to config file")
addr := flag.String("addr", ":8080", "HTTP listen address") addr := flag.String("addr", ":8080", "HTTP listen address")
@@ -34,68 +37,119 @@ func main() {
taskStore := task.NewStore() taskStore := task.NewStore()
cp := copier.New(taskStore) cp := copier.New(taskStore)
var activeDB *db.DB activeDBs := make(map[string]*db.DB)
var activeDiskID string 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) { openDiskDB := func(info disk.DiskInfo) {
if activeDiskID == info.DiskID { if info.DiskID == "" {
return // already open for this disk return
} }
if activeDB != nil {
activeDB.Close() if prevDiskID, ok := mountToDiskID[info.MountPath]; ok && prevDiskID != info.DiskID {
activeDB = nil if prevDB := activeDBs[prevDiskID]; prevDB != nil {
activeDiskID = "" 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)) d, err := db.Open(disk.DBPath(info.MountPath))
if err != nil { if err != nil {
log.Printf("open disk DB: %v", err) log.Printf("open disk DB: %v", err)
return return
} }
activeDB = d activeDBs[info.DiskID] = d
activeDiskID = info.DiskID cp.SetDB(info.DiskID, d)
cp.SetDB(d)
log.Printf("disk DB opened for %s", info.DiskID) log.Printf("disk DB opened for %s", info.DiskID)
resumeDiskTask(info, d)
} }
closeDiskDB := func() { closeDiskDB := func(info disk.DiskInfo) {
if activeDB != nil { diskID := info.DiskID
activeDB.Close() if diskID == "" {
activeDB = nil diskID = mountToDiskID[info.MountPath]
activeDiskID = ""
cp.SetDB(nil)
log.Println("disk DB closed")
} }
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) { w := watcher.New(*mountPath, 5*time.Second, func(ev watcher.DiskEvent) {
log.Printf("disk: %s -> %s", ev.Prev, ev.Info.State) log.Printf("disk: %s %s -> %s", ev.Info.MountPath, ev.Prev.State, ev.Info.State)
switch ev.Info.State { switch ev.Info.State {
case disk.DiskKnown: case disk.DiskKnown:
openDiskDB(ev.Info) openDiskDB(ev.Info)
if ev.Prev != disk.DiskKnown && cfg.AutoCopy { if watcherReady && ev.Prev.State != disk.DiskKnown && cfg.AutoCopy {
triggerAutoCopy(cp, cfg, ev.Info, *mediaPath) triggerAutoCopy(cp, cfg, ev.Info, *mediaPath)
} }
case disk.DiskForeign:
closeDiskDB(ev.Prev)
case disk.DiskAbsent: case disk.DiskAbsent:
closeDiskDB() closeDiskDB(ev.Prev)
} }
}) })
w.ProbeNow()
// Open DB immediately if disk already connected at startup watcherReady = true
{
info, _ := disk.Probe(*mountPath)
if info.State == disk.DiskKnown {
openDiskDB(info)
}
}
srv, err := api.New(api.Deps{ srv, err := api.New(api.Deps{
Config: cfg, Config: cfg,
ConfigPath: *configPath, ConfigPath: *configPath,
Version: Version,
Watcher: w, Watcher: w,
Copier: cp, Copier: cp,
Tasks: taskStore, Tasks: taskStore,
MediaPath: *mediaPath, MediaPath: *mediaPath,
MountPath: *mountPath, MountPath: *mountPath,
OnDiskInit: func(mountPath, diskID string) {
openDiskDB(disk.DiskInfo{
State: disk.DiskKnown,
DiskID: diskID,
MountPath: mountPath,
})
},
}) })
if err != nil { if err != nil {
log.Fatalf("init server: %v", err) log.Fatalf("init server: %v", err)
@@ -119,17 +173,20 @@ func main() {
shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
httpSrv.Shutdown(shutCtx) httpSrv.Shutdown(shutCtx)
closeDiskDB() for _, info := range w.ListDisks() {
closeDiskDB(info)
}
} }
func triggerAutoCopy(cp *copier.Copier, cfg *config.Config, info disk.DiskInfo, mediaPath string) { func triggerAutoCopy(cp *copier.Copier, cfg *config.Config, info disk.DiskInfo, mediaPath string) {
var sources []string hasEnabledSources := false
for _, s := range cfg.Sources { for _, s := range cfg.Sources {
if s.Enabled { if s.Enabled {
sources = append(sources, s.Path) hasEnabledSources = true
break
} }
} }
if len(sources) == 0 { if !hasEnabledSources {
return return
} }
go func() { go func() {
@@ -138,7 +195,7 @@ func triggerAutoCopy(cp *copier.Copier, cfg *config.Config, info disk.DiskInfo,
MountPath: info.MountPath, MountPath: info.MountPath,
MediaPath: mediaPath, MediaPath: mediaPath,
DestFolder: cfg.DestFolder, DestFolder: cfg.DestFolder,
EnabledSources: sources, SourceRules: cfg.Sources,
ReserveFreeGB: cfg.ReserveFreeGB, ReserveFreeGB: cfg.ReserveFreeGB,
OverwriteMode: cfg.OverwriteMode, OverwriteMode: cfg.OverwriteMode,
FileSelectMode: cfg.FileSelectMode, FileSelectMode: cfg.FileSelectMode,

View File

@@ -2,28 +2,61 @@ package api
import ( import (
"context" "context"
"encoding/json"
"errors"
"io"
"net/http" "net/http"
"jukebox_maker/internal/config"
"jukebox_maker/internal/copier" "jukebox_maker/internal/copier"
"jukebox_maker/internal/disk" "jukebox_maker/internal/disk"
) )
func (s *Server) handleCopyStart(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCopyStart(w http.ResponseWriter, r *http.Request) {
diskInfo := s.deps.Watcher.CurrentDisk() diskID := r.PathValue("diskID")
if diskInfo.State != disk.DiskKnown { diskInfo, ok := s.deps.Watcher.DiskByID(diskID)
jsonErr(w, http.StatusUnprocessableEntity, "no known disk connected") if !ok || diskInfo.State != disk.DiskKnown {
jsonErr(w, http.StatusUnprocessableEntity, "no initialized disk connected")
return return
} }
cfg := s.deps.Config cfg := s.deps.Config
var enabledSources []string hasEnabledSources := false
for _, src := range cfg.Sources { for _, src := range cfg.Sources {
if src.Enabled { if src.Enabled {
enabledSources = append(enabledSources, src.Path) hasEnabledSources = true
break
} }
} }
if len(enabledSources) == 0 { if !hasEnabledSources {
jsonErr(w, http.StatusUnprocessableEntity, "no sources enabled") jsonErr(w, http.StatusUnprocessableEntity, "no source folders selected")
return
}
var req struct {
Mode string `json:"mode"`
}
if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
jsonErr(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
}
overwriteMode := cfg.OverwriteMode
switch req.Mode {
case "", "add":
overwriteMode = config.OverwriteSkip
case "replace":
overwriteMode = config.OverwriteDelete
default:
jsonErr(w, http.StatusBadRequest, "invalid copy mode")
return
}
reserveBytes := int64(cfg.ReserveFreeGB * 1e9)
if diskInfo.FreeBytes <= reserveBytes {
jsonErr(w, http.StatusUnprocessableEntity, "free space is below reserve threshold")
return return
} }
@@ -32,9 +65,9 @@ func (s *Server) handleCopyStart(w http.ResponseWriter, r *http.Request) {
MountPath: diskInfo.MountPath, MountPath: diskInfo.MountPath,
MediaPath: s.deps.MediaPath, MediaPath: s.deps.MediaPath,
DestFolder: cfg.DestFolder, DestFolder: cfg.DestFolder,
EnabledSources: enabledSources, SourceRules: cfg.Sources,
ReserveFreeGB: cfg.ReserveFreeGB, ReserveFreeGB: cfg.ReserveFreeGB,
OverwriteMode: cfg.OverwriteMode, OverwriteMode: overwriteMode,
FileSelectMode: cfg.FileSelectMode, FileSelectMode: cfg.FileSelectMode,
} }
@@ -54,7 +87,8 @@ func (s *Server) handleCopyStart(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) handleCopyCancel(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCopyCancel(w http.ResponseWriter, r *http.Request) {
s.deps.Copier.Cancel() diskID := r.PathValue("diskID")
s.deps.Copier.Cancel(diskID)
jsonOK(w, map[string]bool{"ok": true}) jsonOK(w, map[string]bool{"ok": true})
} }

View File

@@ -1,34 +1,82 @@
package api package api
import ( import (
"encoding/json"
"net/http" "net/http"
"time"
"jukebox_maker/internal/disk" "jukebox_maker/internal/disk"
) )
func (s *Server) handleDiskStatus(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDiskStatus(w http.ResponseWriter, r *http.Request) {
info := s.deps.Watcher.CurrentDisk()
type response struct { type response struct {
State disk.DiskState `json:"state"` State disk.DiskState `json:"state"`
DiskID string `json:"disk_id"` DiskID string `json:"disk_id"`
TotalBytes int64 `json:"total_bytes"` TotalBytes int64 `json:"total_bytes"`
FreeBytes int64 `json:"free_bytes"` FreeBytes int64 `json:"free_bytes"`
MountPath string `json:"mount_path"` MountPath string `json:"mount_path"`
LastCopiedAt string `json:"last_copied_at,omitempty"`
ActiveTaskID string `json:"active_task_id,omitempty"` ActiveTaskID string `json:"active_task_id,omitempty"`
} }
resp := response{ disks := s.deps.Watcher.ListDisks()
State: info.State, resp := make([]response, 0, len(disks))
DiskID: info.DiskID, for _, info := range disks {
TotalBytes: info.TotalBytes, item := response{
FreeBytes: info.FreeBytes, State: info.State,
MountPath: info.MountPath, DiskID: info.DiskID,
TotalBytes: info.TotalBytes,
FreeBytes: info.FreeBytes,
MountPath: info.MountPath,
}
if info.DiskID != "" {
if lastCopiedAt, ok, err := s.deps.Copier.LastCopiedAt(info.DiskID); err == nil && ok {
item.LastCopiedAt = lastCopiedAt.Format(time.RFC3339)
}
if t, ok := s.deps.Tasks.ActiveTaskByDisk(info.DiskID); ok {
item.ActiveTaskID = t.ID
}
}
resp = append(resp, item)
} }
if t, ok := s.deps.Tasks.ActiveTask(); ok { jsonOK(w, map[string]any{"items": resp})
resp.ActiveTaskID = t.ID }
}
func (s *Server) handleDiskInit(w http.ResponseWriter, r *http.Request) {
jsonOK(w, resp) var req struct {
MountPath string `json:"mount_path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
info, ok := s.deps.Watcher.DiskByMountPath(req.MountPath)
if !ok {
jsonErr(w, http.StatusNotFound, "disk not found")
return
}
if info.State == disk.DiskAbsent {
jsonErr(w, http.StatusUnprocessableEntity, "no disk connected")
return
}
if info.State == disk.DiskKnown {
jsonErr(w, http.StatusConflict, "disk already initialized")
return
}
if err := disk.CheckWritable(info.MountPath); err != nil {
jsonErr(w, http.StatusUnprocessableEntity, "disk is not writable: "+err.Error())
return
}
diskID, err := disk.InitDisk(info.MountPath)
if err != nil {
jsonErr(w, http.StatusInternalServerError, "init disk: "+err.Error())
return
}
s.deps.OnDiskInit(info.MountPath, diskID)
s.deps.Watcher.ProbeNow()
jsonOK(w, map[string]string{"disk_id": diskID})
} }

View File

@@ -1,26 +1,79 @@
package api package api
import ( import (
"errors"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath"
"sort"
"strings"
) )
func (s *Server) handleSources(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSources(w http.ResponseWriter, r *http.Request) {
entries, err := os.ReadDir(s.deps.MediaPath) relPath, err := normalizeSourcePath(r.URL.Query().Get("path"))
if err != nil { if err != nil {
jsonOK(w, map[string][]string{"items": {}}) jsonErr(w, http.StatusBadRequest, err.Error())
return return
} }
var items []string absPath := s.deps.MediaPath
for _, e := range entries { if relPath != "" {
if e.IsDir() && e.Name()[0] != '.' { absPath = filepath.Join(absPath, relPath)
items = append(items, e.Name())
}
}
if items == nil {
items = []string{}
} }
jsonOK(w, map[string][]string{"items": items}) entries, err := os.ReadDir(absPath)
if err != nil {
jsonOK(w, map[string]any{"path": relPath, "items": []map[string]string{}})
return
}
type item struct {
Name string `json:"name"`
Path string `json:"path"`
}
var items []item
for _, e := range entries {
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
childPath := e.Name()
if relPath != "" {
childPath = filepath.Join(relPath, childPath)
}
items = append(items, item{
Name: e.Name(),
Path: filepath.ToSlash(childPath),
})
}
sort.Slice(items, func(i, j int) bool {
return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name)
})
jsonOK(w, map[string]any{
"path": relPath,
"items": items,
})
}
func normalizeSourcePath(raw string) (string, error) {
raw, _ = url.QueryUnescape(raw)
raw = strings.TrimSpace(raw)
raw = filepath.ToSlash(raw)
raw = strings.TrimPrefix(raw, "/")
if raw == "" || raw == "." {
return "", nil
}
clean := filepath.Clean(raw)
clean = filepath.ToSlash(clean)
if clean == "." {
return "", nil
}
if clean == ".." || strings.HasPrefix(clean, "../") {
return "", errors.New("invalid source path")
}
return clean, nil
} }

View File

@@ -16,11 +16,14 @@ import (
type Deps struct { type Deps struct {
Config *config.Config Config *config.Config
ConfigPath string ConfigPath string
Version string
Watcher *watcher.Watcher Watcher *watcher.Watcher
Copier *copier.Copier Copier *copier.Copier
Tasks *task.Store Tasks *task.Store
MediaPath string MediaPath string
MountPath string MountPath string
// OnDiskInit вызывается при ручной инициализации диска через UI.
OnDiskInit func(mountPath, diskID string)
} }
type Server struct { type Server struct {
@@ -56,12 +59,13 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /settings", s.handleSettings) s.mux.HandleFunc("GET /settings", s.handleSettings)
s.mux.HandleFunc("GET /health", s.handleHealth) s.mux.HandleFunc("GET /health", s.handleHealth)
s.mux.HandleFunc("GET /api/disk", s.handleDiskStatus) s.mux.HandleFunc("GET /api/disks", s.handleDiskStatus)
s.mux.HandleFunc("POST /api/disks/init", s.handleDiskInit)
s.mux.HandleFunc("GET /api/sources", s.handleSources) s.mux.HandleFunc("GET /api/sources", s.handleSources)
s.mux.HandleFunc("GET /api/config", s.handleGetConfig) s.mux.HandleFunc("GET /api/config", s.handleGetConfig)
s.mux.HandleFunc("PUT /api/config", s.handlePutConfig) s.mux.HandleFunc("PUT /api/config", s.handlePutConfig)
s.mux.HandleFunc("POST /api/copy/start", s.handleCopyStart) s.mux.HandleFunc("POST /api/disks/{diskID}/copy/start", s.handleCopyStart)
s.mux.HandleFunc("POST /api/copy/cancel", s.handleCopyCancel) s.mux.HandleFunc("POST /api/disks/{diskID}/copy/cancel", s.handleCopyCancel)
s.mux.HandleFunc("GET /api/tasks/{id}", s.handleTaskGet) s.mux.HandleFunc("GET /api/tasks/{id}", s.handleTaskGet)
} }
@@ -74,7 +78,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
s.render(w, s.settings, map[string]any{"Title": "Настройки", "Page": "settings"}) s.render(w, s.settings, map[string]any{"Title": "Settings", "Page": "settings"})
} }
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
@@ -83,7 +87,13 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
func (s *Server) render(w http.ResponseWriter, tmpl *template.Template, data any) { func (s *Server) render(w http.ResponseWriter, tmpl *template.Template, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil { payload := map[string]any{"Version": s.deps.Version}
if incoming, ok := data.(map[string]any); ok {
for k, v := range incoming {
payload[k] = v
}
}
if err := tmpl.ExecuteTemplate(w, "layout", payload); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }
} }

View File

@@ -5,12 +5,17 @@ import (
"errors" "errors"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"jukebox_maker/internal/disk"
) )
type OverwriteMode string type OverwriteMode string
type FileSelectMode string type FileSelectMode string
const ( const (
DefaultDestFolder = "media"
OverwriteSkip OverwriteMode = "skip" OverwriteSkip OverwriteMode = "skip"
OverwriteDelete OverwriteMode = "delete" OverwriteDelete OverwriteMode = "delete"
@@ -30,12 +35,14 @@ type Config struct {
OverwriteMode OverwriteMode `json:"overwrite_mode"` OverwriteMode OverwriteMode `json:"overwrite_mode"`
FileSelectMode FileSelectMode `json:"file_select_mode"` FileSelectMode FileSelectMode `json:"file_select_mode"`
AutoCopy bool `json:"auto_copy"` AutoCopy bool `json:"auto_copy"`
FileReplicaCounts map[string]int `json:"file_replica_counts,omitempty"`
DiskReplicaFiles map[string][]string `json:"disk_replica_files,omitempty"`
} }
func defaults() Config { func defaults() Config {
return Config{ return Config{
ReserveFreeGB: 2.0, ReserveFreeGB: 2.0,
DestFolder: "media", DestFolder: DefaultDestFolder,
OverwriteMode: OverwriteSkip, OverwriteMode: OverwriteSkip,
FileSelectMode: SelectNew, FileSelectMode: SelectNew,
AutoCopy: false, AutoCopy: false,
@@ -55,6 +62,11 @@ func Load(path string) (*Config, error) {
if err := json.Unmarshal(data, &cfg); err != nil { if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err return nil, err
} }
if destFolder, err := NormalizeDestFolder(cfg.DestFolder); err == nil {
cfg.DestFolder = destFolder
} else {
cfg.DestFolder = defaults().DestFolder
}
return &cfg, nil return &cfg, nil
} }
@@ -77,6 +89,9 @@ func (c *Config) Validate() error {
if c.ReserveFreeGB < 0 { if c.ReserveFreeGB < 0 {
return errors.New("reserve_free_gb must be >= 0") return errors.New("reserve_free_gb must be >= 0")
} }
if _, err := NormalizeDestFolder(c.DestFolder); err != nil {
return err
}
switch c.OverwriteMode { switch c.OverwriteMode {
case OverwriteSkip, OverwriteDelete: case OverwriteSkip, OverwriteDelete:
default: default:
@@ -89,3 +104,26 @@ func (c *Config) Validate() error {
} }
return nil return nil
} }
func NormalizeDestFolder(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return DefaultDestFolder, nil
}
clean := filepath.ToSlash(filepath.Clean(value))
clean = strings.TrimPrefix(clean, "./")
clean = strings.TrimPrefix(clean, "/")
switch clean {
case "", ".", "..":
return "", errors.New("dest_folder must be a subfolder on disk, not the disk root")
}
if strings.HasPrefix(clean, "../") {
return "", errors.New("dest_folder must stay inside the disk")
}
if clean == disk.MarkerDir || strings.HasPrefix(clean, disk.MarkerDir+"/") {
return "", errors.New("dest_folder conflicts with internal disk metadata")
}
return clean, nil
}

View File

@@ -2,13 +2,17 @@ package copier
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"math/rand/v2" "math/rand/v2"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sort"
"strings"
"sync" "sync"
"time"
"jukebox_maker/internal/config" "jukebox_maker/internal/config"
"jukebox_maker/internal/db" "jukebox_maker/internal/db"
@@ -21,7 +25,7 @@ type Options struct {
MountPath string MountPath string
MediaPath string MediaPath string
DestFolder string // subfolder on disk, default "media" DestFolder string // subfolder on disk, default "media"
EnabledSources []string SourceRules []config.SourceFolder
ReserveFreeGB float64 ReserveFreeGB float64
OverwriteMode config.OverwriteMode OverwriteMode config.OverwriteMode
FileSelectMode config.FileSelectMode FileSelectMode config.FileSelectMode
@@ -30,83 +34,178 @@ type Options struct {
type Copier struct { type Copier struct {
tasks *task.Store tasks *task.Store
mu sync.Mutex mu sync.Mutex
cancel context.CancelFunc cancels map[string]context.CancelFunc
dbMu sync.RWMutex dbMu sync.RWMutex
db *db.DB dbs map[string]*db.DB
} }
func New(tasks *task.Store) *Copier { func New(tasks *task.Store) *Copier {
return &Copier{tasks: tasks} return &Copier{
tasks: tasks,
cancels: make(map[string]context.CancelFunc),
dbs: make(map[string]*db.DB),
}
} }
func (c *Copier) SetDB(d *db.DB) { func (c *Copier) SetDB(diskID string, d *db.DB) {
c.dbMu.Lock() c.dbMu.Lock()
c.db = d if d == nil {
delete(c.dbs, diskID)
} else {
c.dbs[diskID] = d
}
c.dbMu.Unlock() c.dbMu.Unlock()
} }
func (c *Copier) getDB() *db.DB { func (c *Copier) getDB(diskID string) *db.DB {
c.dbMu.RLock() c.dbMu.RLock()
defer c.dbMu.RUnlock() defer c.dbMu.RUnlock()
return c.db return c.dbs[diskID]
}
func (c *Copier) LastCopiedAt(diskID string) (time.Time, bool, error) {
database := c.getDB(diskID)
if database == nil {
return time.Time{}, false, nil
}
return database.LastCopiedAt(diskID)
} }
func (c *Copier) Start(ctx context.Context, opts Options) (string, error) { func (c *Copier) Start(ctx context.Context, opts Options) (string, error) {
return c.startTask(ctx, "", opts)
}
func (c *Copier) Resume(ctx context.Context, taskID string, opts Options) error {
_, err := c.startTask(ctx, taskID, opts)
return err
}
func (c *Copier) startTask(ctx context.Context, existingTaskID string, opts Options) (string, error) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if _, active := c.tasks.ActiveTask(); active { if _, active := c.cancels[opts.DiskID]; active {
return "", errors.New("copy already running") return "", errors.New("copy already running")
} }
database := c.getDB() database := c.getDB(opts.DiskID)
if database == nil { if database == nil {
return "", errors.New("no disk database available") return "", errors.New("no disk database available")
} }
if opts.DestFolder == "" { if opts.DestFolder == "" {
opts.DestFolder = "media" opts.DestFolder = config.DefaultDestFolder
}
destFolder, err := config.NormalizeDestFolder(opts.DestFolder)
if err != nil {
destFolder = config.DefaultDestFolder
}
opts.DestFolder = destFolder
_, free, err := disk.DiskUsage(opts.MountPath)
if err != nil {
return "", err
}
reserveBytes := int64(opts.ReserveFreeGB * 1e9)
if free <= reserveBytes {
return "", errors.New("free space is below reserve threshold")
} }
t := c.tasks.Create("copy") var taskID string
copyCtx, cancel := context.WithCancel(ctx) if existingTaskID == "" {
c.cancel = cancel t := c.tasks.Create("copy", opts.DiskID)
payload, err := json.Marshal(opts)
if err != nil {
return "", err
}
if err := database.UpsertTask(*t, payload); err != nil {
return "", err
}
taskID = t.ID
} else {
taskID = existingTaskID
c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusQueued
t.Phase = task.PhaseQueued
t.Message = "Resuming after restart..."
t.Error = ""
t.SpeedBPS = 0
t.ETASec = 0
})
if t, ok := c.tasks.Get(taskID); ok {
if err := database.UpdateTask(*t); err != nil {
return "", err
}
}
}
go c.run(copyCtx, t.ID, opts, database) copyCtx, cancel := context.WithCancel(ctx)
return t.ID, nil c.cancels[opts.DiskID] = cancel
go c.run(copyCtx, taskID, opts, database)
return taskID, nil
} }
func (c *Copier) Cancel() { func (c *Copier) Cancel(diskID string) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if c.cancel != nil { if cancel, ok := c.cancels[diskID]; ok {
c.cancel() cancel()
} }
} }
func (c *Copier) run(ctx context.Context, taskID string, opts Options, database *db.DB) { func (c *Copier) run(ctx context.Context, taskID string, opts Options, database *db.DB) {
defer func() {
c.mu.Lock()
delete(c.cancels, opts.DiskID)
c.mu.Unlock()
}()
setStatus := func(s task.Status, msg string, prog int) { setStatus := func(s task.Status, msg string, prog int) {
c.tasks.Update(taskID, func(t *task.Task) { c.tasks.Update(taskID, func(t *task.Task) {
t.Status = s t.Status = s
t.Message = msg t.Message = msg
t.Progress = prog t.Progress = prog
}) })
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
} }
fail := func(err error) { fail := func(err error) {
c.tasks.Update(taskID, func(t *task.Task) { c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusFailed t.Status = task.StatusFailed
t.Error = err.Error() t.Error = err.Error()
}) })
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
} }
setStatus(task.StatusRunning, "Подготовка…", 0) c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusRunning
t.Phase = task.PhasePreparing
t.Message = "Preparing..."
t.Progress = 0
t.Error = ""
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
destRoot := filepath.Join(opts.MountPath, opts.DestFolder) destRoot := filepath.Join(opts.MountPath, opts.DestFolder)
if opts.OverwriteMode == config.OverwriteDelete { if opts.OverwriteMode == config.OverwriteDelete {
setStatus(task.StatusRunning, "Удаление данных с диска…", 0) c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusRunning
t.Phase = task.PhaseReplacing
t.Message = "Replacing destination media..."
t.Progress = 0
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
if err := os.RemoveAll(destRoot); err != nil { if err := os.RemoveAll(destRoot); err != nil {
fail(err) fail(err)
return return
@@ -115,7 +214,15 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
var copiedPaths map[string]struct{} var copiedPaths map[string]struct{}
if opts.FileSelectMode == config.SelectNew { if opts.FileSelectMode == config.SelectNew {
setStatus(task.StatusRunning, "Загрузка истории…", 0) c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusRunning
t.Phase = task.PhaseLoadingHistory
t.Message = "Loading copy history..."
t.Progress = 0
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
var err error var err error
copiedPaths, err = database.CopiedPaths(opts.DiskID) copiedPaths, err = database.CopiedPaths(opts.DiskID)
if err != nil { if err != nil {
@@ -124,14 +231,22 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
} }
} }
setStatus(task.StatusRunning, "Сканирование источников…", 0) c.tasks.Update(taskID, func(t *task.Task) {
files, err := buildFileList(opts.MediaPath, opts.EnabledSources, copiedPaths) t.Status = task.StatusRunning
t.Phase = task.PhaseScanning
t.Message = "Scanning sources..."
t.Progress = 0
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
files, err := buildFileList(opts.MediaPath, opts.SourceRules, copiedPaths)
if err != nil { if err != nil {
fail(err) fail(err)
return return
} }
if len(files) == 0 { if len(files) == 0 {
setStatus(task.StatusSuccess, "Нет новых файлов для копирования.", 100) setStatus(task.StatusSuccess, "No files to copy.", 100)
return return
} }
@@ -146,47 +261,85 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
reserveBytes := int64(opts.ReserveFreeGB * 1e9) reserveBytes := int64(opts.ReserveFreeGB * 1e9)
available := free - reserveBytes available := free - reserveBytes
if available <= 0 { if available <= 0 {
setStatus(task.StatusSuccess, "Недостаточно свободного места на диске.", 100) setStatus(task.StatusFailed, "Free space is below the reserved threshold.", 100)
return return
} }
// суммарный объём для прогресса (всех файлов в списке)
var totalBytes int64
for _, f := range files {
totalBytes += f.size
}
total := len(files) total := len(files)
copied := 0 copied := 0
var doneBytes int64
startTime := time.Now()
for i, f := range files { for i, f := range files {
select { select {
case <-ctx.Done(): case <-ctx.Done():
c.tasks.Update(taskID, func(t *task.Task) { c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusCanceled t.Status = task.StatusCanceled
t.Message = "Отменено" t.Message = "Canceled"
t.SpeedBPS = 0
t.ETASec = 0
}) })
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
return return
default: default:
} }
if f.size > available { if f.size > available {
// файл не влезает — пробуем следующий
continue continue
} }
prog := int(float64(i+1) / float64(total) * 100) elapsed := time.Since(startTime).Seconds()
msg := fmt.Sprintf("Копирование %s (%d/%d)", filepath.Base(f.srcAbs), i+1, total) var speedBPS, etaSec int64
setStatus(task.StatusRunning, msg, prog) if elapsed > 0 && doneBytes > 0 {
speedBPS = int64(float64(doneBytes) / elapsed)
remaining := totalBytes - doneBytes
if speedBPS > 0 {
etaSec = remaining / speedBPS
}
}
prog := int(float64(doneBytes) / float64(totalBytes) * 100)
msg := fmt.Sprintf("Copying %s (%d/%d)", filepath.Base(f.srcAbs), i+1, total)
c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusRunning
t.Phase = task.PhaseCopying
t.Message = msg
t.Progress = prog
t.SpeedBPS = speedBPS
t.ETASec = int(etaSec)
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
// destination mirrors source structure under destRoot
dstAbs := filepath.Join(destRoot, f.relPath) dstAbs := filepath.Join(destRoot, f.relPath)
if err := rsyncFile(ctx, f.srcAbs, dstAbs); err != nil { if err := rsyncFile(ctx, f.srcAbs, dstAbs); err != nil {
if errors.Is(err, context.Canceled) { if errors.Is(err, context.Canceled) {
c.tasks.Update(taskID, func(t *task.Task) { c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusCanceled t.Status = task.StatusCanceled
t.Message = "Отменено" t.Message = "Canceled"
t.SpeedBPS = 0
t.ETASec = 0
}) })
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
return return
} }
continue continue
} }
available -= f.size available -= f.size
doneBytes += f.size
copied++ copied++
_ = database.RecordCopy(db.CopyRecord{ _ = database.RecordCopy(db.CopyRecord{
DiskID: opts.DiskID, DiskID: opts.DiskID,
@@ -195,7 +348,7 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
}) })
} }
setStatus(task.StatusSuccess, fmt.Sprintf("Готово. Скопировано файлов: %d.", copied), 100) setStatus(task.StatusSuccess, fmt.Sprintf("Done. Copied %d files.", copied), 100)
} }
type fileEntry struct { type fileEntry struct {
@@ -204,15 +357,35 @@ type fileEntry struct {
size int64 size int64
} }
func buildFileList(mediaPath string, sources []string, skip map[string]struct{}) ([]fileEntry, error) { func buildFileList(mediaPath string, rules []config.SourceFolder, skip map[string]struct{}) ([]fileEntry, error) {
roots, ruleMap := normalizeSourceRules(rules)
var result []fileEntry var result []fileEntry
for _, src := range sources { for _, src := range roots {
dir := filepath.Join(mediaPath, src) dir := filepath.Join(mediaPath, src)
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() { if err != nil || d.IsDir() {
if err != nil {
return nil
}
if path == dir {
return nil
}
rel, relErr := filepath.Rel(mediaPath, path)
if relErr != nil {
return nil
}
rel = filepath.ToSlash(rel)
if !isPathEnabled(rel, ruleMap) && !hasEnabledDescendant(rel, ruleMap) {
return filepath.SkipDir
}
return nil return nil
} }
rel, _ := filepath.Rel(mediaPath, path) rel, _ := filepath.Rel(mediaPath, path)
rel = filepath.ToSlash(rel)
if !isPathEnabled(rel, ruleMap) {
return nil
}
if _, skipped := skip[rel]; skipped { if _, skipped := skip[rel]; skipped {
return nil return nil
} }
@@ -230,6 +403,68 @@ func buildFileList(mediaPath string, sources []string, skip map[string]struct{})
return result, nil return result, nil
} }
func normalizeSourceRules(rules []config.SourceFolder) ([]string, map[string]bool) {
ruleMap := make(map[string]bool, len(rules))
for _, rule := range rules {
src := filepath.ToSlash(filepath.Clean(strings.TrimSpace(rule.Path)))
src = strings.TrimPrefix(src, "./")
src = strings.TrimPrefix(src, "/")
if src == "" || src == "." {
continue
}
if src == ".." || strings.HasPrefix(src, "../") {
continue
}
ruleMap[src] = rule.Enabled
}
var roots []string
for src, enabled := range ruleMap {
if !enabled || hasEnabledAncestor(src, ruleMap) {
continue
}
roots = append(roots, src)
}
sort.Strings(roots)
return roots, ruleMap
}
func hasEnabledAncestor(path string, ruleMap map[string]bool) bool {
for parent := parentSourcePath(path); parent != ""; parent = parentSourcePath(parent) {
if ruleMap[parent] {
return true
}
}
return false
}
func hasEnabledDescendant(path string, ruleMap map[string]bool) bool {
prefix := path + "/"
for other, enabled := range ruleMap {
if enabled && strings.HasPrefix(other, prefix) {
return true
}
}
return false
}
func isPathEnabled(path string, ruleMap map[string]bool) bool {
for current := path; current != ""; current = parentSourcePath(current) {
if enabled, ok := ruleMap[current]; ok {
return enabled
}
}
return false
}
func parentSourcePath(path string) string {
idx := strings.LastIndex(path, "/")
if idx < 0 {
return ""
}
return path[:idx]
}
// rsyncFile copies src to dst using rsync with resume support. // rsyncFile copies src to dst using rsync with resume support.
// --partial keeps partial files on interruption. // --partial keeps partial files on interruption.
// --append-verify resumes partial transfers and verifies checksums. // --append-verify resumes partial transfers and verifies checksums.

View File

@@ -2,8 +2,11 @@ package db
import ( import (
"database/sql" "database/sql"
"encoding/json"
"time" "time"
"jukebox_maker/internal/task"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -18,6 +21,11 @@ type CopyRecord struct {
CopiedAt time.Time CopiedAt time.Time
} }
type TaskRecord struct {
Task task.Task
Payload json.RawMessage
}
func Open(path string) (*DB, error) { func Open(path string) (*DB, error) {
conn, err := sql.Open("sqlite", path+"?_journal=WAL&_timeout=5000") conn, err := sql.Open("sqlite", path+"?_journal=WAL&_timeout=5000")
if err != nil { if err != nil {
@@ -47,6 +55,26 @@ func (d *DB) migrate() error {
); );
CREATE UNIQUE INDEX IF NOT EXISTS idx_copy_history_disk_path CREATE UNIQUE INDEX IF NOT EXISTS idx_copy_history_disk_path
ON copy_history (disk_id, source_path); ON copy_history (disk_id, source_path);
CREATE TABLE IF NOT EXISTS disk_stats (
disk_id TEXT PRIMARY KEY,
last_copied_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
disk_id TEXT NOT NULL,
type TEXT NOT NULL,
status TEXT NOT NULL,
phase TEXT NOT NULL DEFAULT 'queued',
progress INTEGER NOT NULL DEFAULT 0,
message TEXT NOT NULL DEFAULT '',
speed_bps INTEGER NOT NULL DEFAULT 0,
eta_sec INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT '',
payload TEXT NOT NULL DEFAULT '{}',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tasks_status_updated ON tasks (status, updated_at);
`) `)
return err return err
} }
@@ -65,11 +93,26 @@ func (d *DB) RecordCopy(rec CopyRecord) error {
if t.IsZero() { if t.IsZero() {
t = time.Now().UTC() t = time.Now().UTC()
} }
_, err := d.sql.Exec( tx, err := d.sql.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(
`INSERT OR IGNORE INTO copy_history (disk_id, source_path, file_size, copied_at) VALUES (?,?,?,?)`, `INSERT OR IGNORE INTO copy_history (disk_id, source_path, file_size, copied_at) VALUES (?,?,?,?)`,
rec.DiskID, rec.SourcePath, rec.FileSize, t.Format(time.RFC3339), rec.DiskID, rec.SourcePath, rec.FileSize, t.Format(time.RFC3339),
) ); err != nil {
return err return err
}
if _, err := tx.Exec(
`INSERT INTO disk_stats (disk_id, last_copied_at) VALUES (?, ?)
ON CONFLICT(disk_id) DO UPDATE SET last_copied_at=excluded.last_copied_at`,
rec.DiskID, t.Format(time.RFC3339),
); err != nil {
return err
}
return tx.Commit()
} }
func (d *DB) CopiedPaths(diskID string) (map[string]struct{}, error) { func (d *DB) CopiedPaths(diskID string) (map[string]struct{}, error) {
@@ -90,3 +133,114 @@ func (d *DB) CopiedPaths(diskID string) (map[string]struct{}, error) {
} }
return m, rows.Err() return m, rows.Err()
} }
func (d *DB) LastCopiedAt(diskID string) (time.Time, bool, error) {
var raw string
err := d.sql.QueryRow(
`SELECT last_copied_at FROM disk_stats WHERE disk_id=?`,
diskID,
).Scan(&raw)
if err == sql.ErrNoRows {
return time.Time{}, false, nil
}
if err != nil {
return time.Time{}, false, err
}
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}, false, err
}
return t, true, nil
}
func (d *DB) UpsertTask(t task.Task, payload json.RawMessage) error {
if payload == nil {
payload = json.RawMessage(`{}`)
}
_, err := d.sql.Exec(
`INSERT INTO tasks (id, disk_id, type, status, phase, progress, message, speed_bps, eta_sec, error, payload, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
status=excluded.status,
phase=excluded.phase,
progress=excluded.progress,
message=excluded.message,
speed_bps=excluded.speed_bps,
eta_sec=excluded.eta_sec,
error=excluded.error,
payload=excluded.payload,
updated_at=excluded.updated_at`,
t.ID, t.DiskID, t.Type, t.Status, t.Phase, t.Progress, t.Message, t.SpeedBPS, t.ETASec, t.Error,
string(payload), t.CreatedAt.Format(time.RFC3339), t.UpdatedAt.Format(time.RFC3339),
)
return err
}
func (d *DB) UpdateTask(t task.Task) error {
_, err := d.sql.Exec(
`UPDATE tasks
SET status=?, phase=?, progress=?, message=?, speed_bps=?, eta_sec=?, error=?, updated_at=?
WHERE id=?`,
t.Status, t.Phase, t.Progress, t.Message, t.SpeedBPS, t.ETASec, t.Error, t.UpdatedAt.Format(time.RFC3339), t.ID,
)
return err
}
func (d *DB) ActiveTask() (*TaskRecord, bool, error) {
row := d.sql.QueryRow(
`SELECT id, disk_id, type, status, phase, progress, message, speed_bps, eta_sec, error, payload, created_at, updated_at
FROM tasks
WHERE status IN ('queued','running')
ORDER BY updated_at DESC
LIMIT 1`,
)
rec, err := scanTaskRecord(row)
if err == sql.ErrNoRows {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return rec, true, nil
}
type scanner interface {
Scan(dest ...any) error
}
func scanTaskRecord(s scanner) (*TaskRecord, error) {
var rec TaskRecord
var payloadRaw, createdAtRaw, updatedAtRaw string
err := s.Scan(
&rec.Task.ID,
&rec.Task.DiskID,
&rec.Task.Type,
&rec.Task.Status,
&rec.Task.Phase,
&rec.Task.Progress,
&rec.Task.Message,
&rec.Task.SpeedBPS,
&rec.Task.ETASec,
&rec.Task.Error,
&payloadRaw,
&createdAtRaw,
&updatedAtRaw,
)
if err != nil {
return nil, err
}
createdAt, err := time.Parse(time.RFC3339, createdAtRaw)
if err != nil {
return nil, err
}
updatedAt, err := time.Parse(time.RFC3339, updatedAtRaw)
if err != nil {
return nil, err
}
rec.Task.CreatedAt = createdAt
rec.Task.UpdatedAt = updatedAt
rec.Payload = json.RawMessage(payloadRaw)
return &rec, nil
}

View File

@@ -26,14 +26,13 @@ type DiskInfo struct {
MountPath string `json:"mount_path"` MountPath string `json:"mount_path"`
} }
const markerDir = ".jukebox" const MarkerDir = ".jukebox"
const idFile = "disk.id" const idFile = "disk.id"
func Probe(mountPath string) (DiskInfo, error) { func Probe(mountPath string) (DiskInfo, error) {
info := DiskInfo{MountPath: mountPath, State: DiskAbsent} info := DiskInfo{MountPath: mountPath, State: DiskAbsent}
entries, err := os.ReadDir(mountPath) if _, err := os.ReadDir(mountPath); err != nil {
if err != nil || len(entries) == 0 {
return info, nil return info, nil
} }
@@ -44,7 +43,7 @@ func Probe(mountPath string) (DiskInfo, error) {
info.TotalBytes = total info.TotalBytes = total
info.FreeBytes = free info.FreeBytes = free
idPath := filepath.Join(mountPath, markerDir, idFile) idPath := filepath.Join(mountPath, MarkerDir, idFile)
data, err := os.ReadFile(idPath) data, err := os.ReadFile(idPath)
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
info.State = DiskForeign info.State = DiskForeign
@@ -60,8 +59,45 @@ func Probe(mountPath string) (DiskInfo, error) {
return info, nil return info, nil
} }
func IsMountPoint(path string) bool {
pathInfo, err := os.Stat(path)
if err != nil {
return false
}
parent := filepath.Dir(filepath.Clean(path))
parentInfo, err := os.Stat(parent)
if err != nil {
return false
}
pathStat, ok := pathInfo.Sys().(*syscall.Stat_t)
if !ok {
return false
}
parentStat, ok := parentInfo.Sys().(*syscall.Stat_t)
if !ok {
return false
}
return pathStat.Dev != parentStat.Dev
}
func CheckWritable(path string) error {
f, err := os.CreateTemp(path, ".jukebox-writecheck-*")
if err != nil {
return err
}
name := f.Name()
if err := f.Close(); err != nil {
_ = os.Remove(name)
return err
}
return os.Remove(name)
}
func InitDisk(mountPath string) (string, error) { func InitDisk(mountPath string) (string, error) {
dir := filepath.Join(mountPath, markerDir) dir := filepath.Join(mountPath, MarkerDir)
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err return "", err
} }
@@ -74,7 +110,7 @@ func InitDisk(mountPath string) (string, error) {
} }
func DBPath(mountPath string) string { func DBPath(mountPath string) string {
return filepath.Join(mountPath, markerDir, "history.db") return filepath.Join(mountPath, MarkerDir, "history.db")
} }
func DiskUsage(mountPath string) (total, free int64, err error) { func DiskUsage(mountPath string) (total, free int64, err error) {

View File

@@ -17,12 +17,25 @@ const (
StatusCanceled Status = "canceled" StatusCanceled Status = "canceled"
) )
const (
PhaseQueued = "queued"
PhasePreparing = "preparing"
PhaseReplacing = "replacing"
PhaseLoadingHistory = "loading_history"
PhaseScanning = "scanning"
PhaseCopying = "copying"
)
type Task struct { type Task struct {
ID string `json:"id"` ID string `json:"id"`
DiskID string `json:"disk_id"`
Type string `json:"type"` Type string `json:"type"`
Status Status `json:"status"` Status Status `json:"status"`
Phase string `json:"phase,omitempty"`
Progress int `json:"progress"` Progress int `json:"progress"`
Message string `json:"message"` Message string `json:"message"`
SpeedBPS int64 `json:"speed_bps"`
ETASec int `json:"eta_sec"`
Error string `json:"error"` Error string `json:"error"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@@ -41,13 +54,16 @@ func NewStore() *Store {
return &Store{tasks: make(map[string]*Task)} return &Store{tasks: make(map[string]*Task)}
} }
func (s *Store) Create(taskType string) *Task { func (s *Store) Create(taskType, diskID string) *Task {
now := time.Now().UTC()
t := &Task{ t := &Task{
ID: uuid.New().String(), ID: uuid.New().String(),
DiskID: diskID,
Type: taskType, Type: taskType,
Status: StatusQueued, Status: StatusQueued,
CreatedAt: time.Now().UTC(), Phase: PhaseQueued,
UpdatedAt: time.Now().UTC(), CreatedAt: now,
UpdatedAt: now,
} }
s.mu.Lock() s.mu.Lock()
s.tasks[t.ID] = t s.tasks[t.ID] = t
@@ -55,6 +71,13 @@ func (s *Store) Create(taskType string) *Task {
return t return t
} }
func (s *Store) Upsert(t Task) {
copy := t
s.mu.Lock()
s.tasks[t.ID] = &copy
s.mu.Unlock()
}
func (s *Store) Get(id string) (*Task, bool) { func (s *Store) Get(id string) (*Task, bool) {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
@@ -75,11 +98,11 @@ func (s *Store) Update(id string, fn func(*Task)) {
} }
} }
func (s *Store) ActiveTask() (*Task, bool) { func (s *Store) ActiveTaskByDisk(diskID string) (*Task, bool) {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
for _, t := range s.tasks { for _, t := range s.tasks {
if t.Status == StatusQueued || t.Status == StatusRunning { if t.DiskID == diskID && (t.Status == StatusQueued || t.Status == StatusRunning) {
copy := *t copy := *t
return &copy, true return &copy, true
} }

View File

@@ -2,6 +2,10 @@ package watcher
import ( import (
"context" "context"
"os"
"path/filepath"
"sort"
"strings"
"sync" "sync"
"time" "time"
@@ -10,7 +14,7 @@ import (
type DiskEvent struct { type DiskEvent struct {
Info disk.DiskInfo Info disk.DiskInfo
Prev disk.DiskState Prev disk.DiskInfo
} }
type Handler func(event DiskEvent) type Handler func(event DiskEvent)
@@ -20,8 +24,8 @@ type Watcher struct {
interval time.Duration interval time.Duration
handler Handler handler Handler
mu sync.RWMutex mu sync.RWMutex
current disk.DiskInfo disks map[string]disk.DiskInfo
} }
func New(mountPath string, interval time.Duration, handler Handler) *Watcher { func New(mountPath string, interval time.Duration, handler Handler) *Watcher {
@@ -29,13 +33,42 @@ func New(mountPath string, interval time.Duration, handler Handler) *Watcher {
mountPath: mountPath, mountPath: mountPath,
interval: interval, interval: interval,
handler: handler, handler: handler,
disks: make(map[string]disk.DiskInfo),
} }
} }
func (w *Watcher) CurrentDisk() disk.DiskInfo { func (w *Watcher) ListDisks() []disk.DiskInfo {
w.mu.RLock() w.mu.RLock()
defer w.mu.RUnlock() defer w.mu.RUnlock()
return w.current
items := make([]disk.DiskInfo, 0, len(w.disks))
for _, info := range w.disks {
items = append(items, info)
}
sort.Slice(items, func(i, j int) bool { return items[i].MountPath < items[j].MountPath })
return items
}
func (w *Watcher) DiskByMountPath(mountPath string) (disk.DiskInfo, bool) {
w.mu.RLock()
defer w.mu.RUnlock()
info, ok := w.disks[mountPath]
return info, ok
}
func (w *Watcher) DiskByID(diskID string) (disk.DiskInfo, bool) {
w.mu.RLock()
defer w.mu.RUnlock()
for _, info := range w.disks {
if info.DiskID == diskID {
return info, true
}
}
return disk.DiskInfo{}, false
}
func (w *Watcher) ProbeNow() {
w.probe()
} }
func (w *Watcher) Run(ctx context.Context) { func (w *Watcher) Run(ctx context.Context) {
@@ -56,15 +89,69 @@ func (w *Watcher) Run(ctx context.Context) {
} }
func (w *Watcher) probe() { func (w *Watcher) probe() {
info, _ := disk.Probe(w.mountPath) next := discoverDisks(w.mountPath)
w.mu.Lock() w.mu.Lock()
prev := w.current.State prev := w.disks
changed := prev != info.State w.disks = next
w.current = info
w.mu.Unlock() w.mu.Unlock()
if changed && w.handler != nil { if w.handler == nil {
w.handler(DiskEvent{Info: info, Prev: prev}) return
}
seen := make(map[string]struct{}, len(prev)+len(next))
for mountPath, info := range next {
seen[mountPath] = struct{}{}
prevInfo := prev[mountPath]
if prevInfo.State != info.State || prevInfo.DiskID != info.DiskID {
w.handler(DiskEvent{Info: info, Prev: prevInfo})
}
}
for mountPath, prevInfo := range prev {
if _, ok := seen[mountPath]; ok {
continue
}
w.handler(DiskEvent{
Info: disk.DiskInfo{
State: disk.DiskAbsent,
MountPath: mountPath,
},
Prev: prevInfo,
})
} }
} }
func discoverDisks(root string) map[string]disk.DiskInfo {
disks := make(map[string]disk.DiskInfo)
entries, err := os.ReadDir(root)
if err != nil {
return disks
}
for _, entry := range entries {
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
mountPath := filepath.Join(root, entry.Name())
if !disk.IsMountPoint(mountPath) {
continue
}
info, _ := disk.Probe(mountPath)
if info.State == disk.DiskAbsent {
continue
}
disks[mountPath] = info
}
// If no child mountpoints were detected, the disk may be mounted directly at root.
if len(disks) == 0 {
if disk.IsMountPoint(root) {
if info, _ := disk.Probe(root); info.State != disk.DiskAbsent {
disks[root] = info
}
}
}
return disks
}

View File

@@ -21,8 +21,10 @@ ROOT_DIR=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
die() { echo "error: $*" >&2; exit 1; } die() { echo "error: $*" >&2; exit 1; }
command -v docker >/dev/null 2>&1 || die "docker not found in PATH" command -v docker >/dev/null 2>&1 || die "docker not found in PATH"
command -v go >/dev/null 2>&1 || die "go not found in PATH"
DEFAULT_TAG=$(git -C "${ROOT_DIR}" rev-parse --short HEAD 2>/dev/null || echo dev) DEFAULT_TAG=$(git -C "${ROOT_DIR}" rev-parse --short HEAD 2>/dev/null || echo dev)
DEFAULT_VERSION=$(git -C "${ROOT_DIR}" describe --tags --always 2>/dev/null || echo dev)
ask() { ask() {
# $1=varname $2=prompt $3=default # $1=varname $2=prompt $3=default
@@ -46,6 +48,12 @@ else
ask IMAGE "Image" "" ask IMAGE "Image" ""
fi fi
echo "checking Go build"
(
cd "${ROOT_DIR}"
go build ./...
)
if [ -n "${IMAGE}" ]; then if [ -n "${IMAGE}" ]; then
# multi-arch build + push # multi-arch build + push
docker buildx version >/dev/null 2>&1 || die "docker buildx not available" docker buildx version >/dev/null 2>&1 || die "docker buildx not available"
@@ -66,6 +74,7 @@ if [ -n "${IMAGE}" ]; then
docker buildx build \ docker buildx build \
--platform "${PLATFORMS}" \ --platform "${PLATFORMS}" \
--file "${ROOT_DIR}/Dockerfile" \ --file "${ROOT_DIR}/Dockerfile" \
--build-arg "VERSION=${DEFAULT_VERSION}" \
-t "${IMAGE}:${IMAGE_TAG}" \ -t "${IMAGE}:${IMAGE_TAG}" \
-t "${IMAGE}:latest" \ -t "${IMAGE}:latest" \
--push \ --push \
@@ -80,6 +89,7 @@ else
echo "building locally (no push)" echo "building locally (no push)"
docker build \ docker build \
--file "${ROOT_DIR}/Dockerfile" \ --file "${ROOT_DIR}/Dockerfile" \
--build-arg "VERSION=${DEFAULT_VERSION}" \
-t "jukebox-maker:${IMAGE_TAG}" \ -t "jukebox-maker:${IMAGE_TAG}" \
-t "jukebox-maker:latest" \ -t "jukebox-maker:latest" \
"${ROOT_DIR}" "${ROOT_DIR}"

View File

@@ -69,6 +69,14 @@ a:hover { text-decoration: underline; }
margin: 28px auto 56px; margin: 28px auto 56px;
} }
.page-footer {
width: min(var(--content-width), calc(100vw - 48px));
margin: -28px auto 24px;
color: var(--muted);
font-size: 12px;
text-align: right;
}
/* Panel */ /* Panel */
.panel { .panel {
margin-bottom: 24px; margin-bottom: 24px;
@@ -89,6 +97,24 @@ a:hover { text-decoration: underline; }
color: var(--ink); color: var(--ink);
} }
.panel-body {
padding: 14px 16px;
}
.disk-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
}
.disk-card {
margin-bottom: 0;
}
.progress-wrap {
border-top: 1px solid var(--border-lite);
}
/* KV table */ /* KV table */
.kv-table { .kv-table {
width: 100%; width: 100%;
@@ -241,6 +267,69 @@ a:hover { text-decoration: underline; }
/* Checkbox list */ /* Checkbox list */
.source-list { display: flex; flex-direction: column; gap: 0; } .source-list { display: flex; flex-direction: column; gap: 0; }
.source-tree {
padding: 8px 0;
}
.source-tree-empty {
padding: 12px 16px;
}
.source-node {
border-bottom: 1px solid var(--border-lite);
}
.source-node:last-child {
border-bottom: 0;
}
.source-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
}
.source-row:hover {
background: rgba(33, 133, 208, 0.04);
}
.source-toggle {
width: 24px;
height: 24px;
border: 1px solid transparent;
border-radius: var(--radius);
background: transparent;
color: var(--muted);
cursor: pointer;
flex: 0 0 24px;
}
.source-toggle:hover {
border-color: var(--border);
background: var(--surface-2);
}
.source-toggle-empty {
visibility: hidden;
}
.source-check {
width: 15px;
height: 15px;
accent-color: var(--accent);
cursor: pointer;
}
.source-label {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.source-item-hint {
font-size: 12px;
color: var(--muted);
}
.source-children {
padding-left: 20px;
}
.source-loading {
padding: 6px 16px 10px 48px;
color: var(--muted);
font-size: 12px;
}
.source-item { .source-item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -325,5 +414,8 @@ a:hover { text-decoration: underline; }
@media (max-width: 720px) { @media (max-width: 720px) {
.page-header { flex-wrap: wrap; padding: 12px 16px; } .page-header { flex-wrap: wrap; padding: 12px 16px; }
.page-main { width: calc(100vw - 24px); margin-top: 20px; } .page-main { width: calc(100vw - 24px); margin-top: 20px; }
.page-footer { width: calc(100vw - 24px); margin-top: -8px; }
.disk-grid { grid-template-columns: 1fr; }
.kv-table th { width: 130px; } .kv-table th { width: 130px; }
.btn-row { flex-wrap: wrap; }
} }

View File

@@ -1,137 +1,260 @@
{{define "content"}} {{define "content"}}
<section class="panel"> <section class="panel">
<h2>Накопитель</h2> <h2>Disks</h2>
<table class="kv-table"> <div class="panel-body">
<tbody> <div id="diskSummary" class="text-muted">Loading disks...</div>
<tr>
<th>Статус</th>
<td id="diskState"><span class="badge badge-unknown">Не подключён</span></td>
</tr>
<tr id="rowDiskID" class="hidden">
<th>ID диска</th>
<td><span class="mono" id="valDiskID"></span></td>
</tr>
<tr id="rowTotal" class="hidden">
<th>Всего на диске</th>
<td id="valTotal"></td>
</tr>
<tr id="rowFree" class="hidden">
<th>Свободно</th>
<td id="valFree"></td>
</tr>
</tbody>
</table>
</section>
<section class="panel hidden" id="progressPanel">
<h2>Копирование</h2>
<div style="padding:14px 16px">
<div class="progress-bar-bg">
<div class="progress-bar-fill" id="progressFill" style="width:0%"></div>
</div>
<div class="progress-label" id="progressMsg">Подготовка…</div>
</div> </div>
</section> </section>
<div class="btn-row" style="background:transparent;border:none;padding:0;margin-bottom:24px"> <div class="disk-grid" id="diskGrid"></div>
<button class="button-primary" id="btnStart" onclick="startCopy()" disabled>▶ Запустить копирование</button>
<button class="button-danger hidden" id="btnCancel" onclick="cancelCopy()">✕ Отменить</button>
</div>
<script> <script>
let pollInterval = null; let disks = [];
let activeTaskId = null; const taskState = new Map();
const taskPollers = new Map();
async function refreshDisk() { function escapeHTML(value) {
try { return String(value || '').replace(/[&<>"']/g, (char) => ({
const r = await fetch('/api/disk'); '&': '&amp;',
if (!r.ok) return; '<': '&lt;',
const d = await r.json(); '>': '&gt;',
'"': '&quot;',
const labels = { absent: 'Не подключён', foreign: 'Незнакомый диск', known: 'Диск подключён' }; "'": '&#39;'
const cls = { absent: 'badge-unknown', foreign: 'badge-warn', known: 'badge-ok' }; }[char]));
document.getElementById('diskState').innerHTML =
`<span class="badge ${cls[d.state]||'badge-unknown'}">${labels[d.state]||'—'}</span>`;
const known = d.state === 'known';
['rowDiskID','rowTotal','rowFree'].forEach(id =>
document.getElementById(id).classList.toggle('hidden', !known));
if (known) {
document.getElementById('valDiskID').textContent = d.disk_id;
document.getElementById('valTotal').textContent = fmtBytes(d.total_bytes);
document.getElementById('valFree').textContent = fmtBytes(d.free_bytes);
}
const hasTask = !!d.active_task_id;
document.getElementById('btnStart').disabled = !known || hasTask;
document.getElementById('btnStart').classList.toggle('hidden', hasTask);
document.getElementById('btnCancel').classList.toggle('hidden', !hasTask);
document.getElementById('progressPanel').classList.toggle('hidden', !hasTask);
if (d.active_task_id && d.active_task_id !== activeTaskId) {
activeTaskId = d.active_task_id;
startTaskPoll(activeTaskId);
}
if (!d.active_task_id && activeTaskId) {
activeTaskId = null; stopTaskPoll();
document.getElementById('progressPanel').classList.add('hidden');
}
} catch(e) {}
} }
function startTaskPoll(id) { stopTaskPoll(); pollInterval = setInterval(() => pollTask(id), 1500); } function diskKey(disk) {
function stopTaskPoll() { if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } } return disk.disk_id || disk.mount_path;
async function pollTask(id) {
try {
const r = await fetch('/api/tasks/' + id);
if (!r.ok) return;
const t = await r.json();
document.getElementById('progressFill').style.width = t.progress + '%';
document.getElementById('progressMsg').textContent = t.message || '…';
if (['success','failed','canceled'].includes(t.status)) {
stopTaskPoll(); activeTaskId = null;
document.getElementById('btnStart').disabled = false;
document.getElementById('btnStart').classList.remove('hidden');
document.getElementById('btnCancel').classList.add('hidden');
document.getElementById('progressPanel').classList.add('hidden');
if (t.status === 'success') toast(t.message || 'Готово', 'ok');
if (t.status === 'failed') toast('Ошибка: ' + t.error, 'error');
if (t.status === 'canceled') toast('Копирование отменено', 'error');
refreshDisk();
}
} catch(e) {}
} }
async function startCopy() { function badgeClass(state) {
document.getElementById('btnStart').disabled = true; return ({ absent: 'badge-unknown', foreign: 'badge-warn', known: 'badge-ok' })[state] || 'badge-unknown';
}
function badgeLabel(state) {
return ({ absent: 'Not connected', foreign: 'Uninitialized disk', known: 'Ready' })[state] || '—';
}
function fmtSpeed(bps) {
if (!bps) return '';
if (bps >= 1e9) return (bps / 1e9).toFixed(1) + ' GB/s';
if (bps >= 1e6) return (bps / 1e6).toFixed(1) + ' MB/s';
if (bps >= 1e3) return (bps / 1e3).toFixed(0) + ' KB/s';
return bps + ' B/s';
}
function fmtETA(sec) {
if (!sec || sec <= 0) return '';
if (sec >= 3600) return Math.floor(sec / 3600) + ' h ' + Math.floor((sec % 3600) / 60) + ' min';
if (sec >= 60) return Math.floor(sec / 60) + ' min';
return sec + ' s';
}
function fmtDateTime(value) {
if (!value) return 'Never';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
function taskMeta(task) {
if (!task) return '';
return [fmtSpeed(task.speed_bps), task.eta_sec ? 'ETA: ' + fmtETA(task.eta_sec) : ''].filter(Boolean).join(' · ');
}
function renderDisks() {
const grid = document.getElementById('diskGrid');
const summary = document.getElementById('diskSummary');
if (!disks.length) {
summary.textContent = 'No disks found.';
grid.innerHTML = '';
return;
}
const knownCount = disks.filter((disk) => disk.state === 'known').length;
summary.textContent = `Disks found: ${disks.length}. Ready to copy: ${knownCount}.`;
grid.innerHTML = disks.map((disk) => {
const activeTask = disk.active_task_id ? taskState.get(disk.active_task_id) : null;
const progress = activeTask ? activeTask.progress : 0;
const message = activeTask ? (activeTask.message || 'Preparing...') : '';
const meta = activeTask ? taskMeta(activeTask) : '';
const isKnown = disk.state === 'known';
const isForeign = disk.state === 'foreign';
const hasCapacity = disk.state !== 'absent';
return `
<section class="panel disk-card">
<h2>${escapeHTML(disk.mount_path)}</h2>
<table class="kv-table">
<tbody>
<tr>
<th>Status</th>
<td><span class="badge ${badgeClass(disk.state)}">${badgeLabel(disk.state)}</span></td>
</tr>
<tr>
<th>Disk ID</th>
<td>${disk.disk_id ? `<span class="mono">${escapeHTML(disk.disk_id)}</span>` : '<span class="text-muted">not initialized yet</span>'}</td>
</tr>
<tr>
<th>Total capacity</th>
<td>${hasCapacity ? fmtBytes(disk.total_bytes) : '—'}</td>
</tr>
<tr>
<th>Free space</th>
<td>${hasCapacity ? fmtBytes(disk.free_bytes) : '—'}</td>
</tr>
<tr>
<th>Last copied</th>
<td>${fmtDateTime(disk.last_copied_at)}</td>
</tr>
</tbody>
</table>
${activeTask ? `
<div class="panel-body progress-wrap">
<div class="progress-bar-bg">
<div class="progress-bar-fill" style="width:${progress}%"></div>
</div>
<div class="progress-label">${escapeHTML(message)}</div>
<div class="progress-label">${escapeHTML(meta)}</div>
</div>
` : ''}
<div class="btn-row">
${isKnown ? `
<button class="button-danger" data-action="start-copy" data-mode="replace" data-disk-id="${escapeHTML(disk.disk_id)}" ${activeTask ? 'disabled' : ''}>Replace media</button>
<button class="button-primary" data-action="start-copy" data-mode="add" data-disk-id="${escapeHTML(disk.disk_id)}" ${activeTask ? 'disabled' : ''}>Add media</button>
<button class="button-danger ${activeTask ? '' : 'hidden'}" data-action="cancel-copy" data-disk-id="${escapeHTML(disk.disk_id)}">Cancel</button>
` : ''}
${isForeign ? `
<button class="button-secondary" data-action="init-disk" data-mount-path="${escapeHTML(disk.mount_path)}">Initialize disk</button>
` : ''}
</div>
</section>
`;
}).join('');
}
function stopTaskPoll(taskID) {
if (!taskPollers.has(taskID)) return;
clearInterval(taskPollers.get(taskID));
taskPollers.delete(taskID);
}
function startTaskPoll(taskID) {
if (!taskID || taskPollers.has(taskID)) return;
taskPollers.set(taskID, setInterval(() => pollTask(taskID), 1500));
pollTask(taskID);
}
async function refreshDisks() {
try { try {
const r = await fetch('/api/copy/start', { method: 'POST' }); const response = await fetch('/api/disks');
const d = await r.json(); if (!response.ok) return;
if (!r.ok) { const payload = await response.json();
toast(d.error || 'Ошибка запуска', 'error'); disks = payload.items || [];
document.getElementById('btnStart').disabled = false; renderDisks();
const activeTasks = new Set();
for (const disk of disks) {
if (disk.active_task_id) {
activeTasks.add(disk.active_task_id);
startTaskPoll(disk.active_task_id);
}
}
for (const taskID of Array.from(taskPollers.keys())) {
if (!activeTasks.has(taskID)) {
stopTaskPoll(taskID);
taskState.delete(taskID);
}
}
} catch (error) {}
}
async function pollTask(taskID) {
try {
const response = await fetch('/api/tasks/' + taskID);
if (!response.ok) return;
const task = await response.json();
taskState.set(taskID, task);
renderDisks();
if (['success', 'failed', 'canceled'].includes(task.status)) {
stopTaskPoll(taskID);
taskState.delete(taskID);
if (task.status === 'success') toast(task.message || 'Done', 'ok');
if (task.status === 'failed') toast('Error: ' + task.error, 'error');
if (task.status === 'canceled') toast('Copy canceled', 'error');
refreshDisks();
}
} catch (error) {}
}
async function startCopy(diskID, mode) {
try {
const response = await fetch('/api/disks/' + encodeURIComponent(diskID) + '/copy/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode })
});
const payload = await response.json();
if (!response.ok) {
toast(payload.error || 'Failed to start copy', 'error');
return; return;
} }
activeTaskId = d.task_id; startTaskPoll(payload.task_id);
document.getElementById('btnStart').classList.add('hidden'); refreshDisks();
document.getElementById('btnCancel').classList.remove('hidden'); } catch (error) {
document.getElementById('progressPanel').classList.remove('hidden'); toast('Network error', 'error');
document.getElementById('progressFill').style.width = '0%';
document.getElementById('progressMsg').textContent = 'Подготовка…';
startTaskPoll(activeTaskId);
} catch(e) {
toast('Ошибка связи', 'error');
document.getElementById('btnStart').disabled = false;
} }
} }
async function cancelCopy() { async function cancelCopy(diskID) {
try { await fetch('/api/copy/cancel', { method: 'POST' }); toast('Отмена…', 'ok'); } catch(e) {} try {
await fetch('/api/disks/' + encodeURIComponent(diskID) + '/copy/cancel', { method: 'POST' });
toast('Canceling...', 'ok');
} catch (error) {
toast('Network error', 'error');
}
} }
refreshDisk(); async function initDisk(mountPath) {
setInterval(refreshDisk, 5000); try {
const response = await fetch('/api/disks/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mount_path: mountPath })
});
const payload = await response.json();
if (!response.ok) {
toast(payload.error || 'Failed to initialize disk', 'error');
return;
}
toast('Disk initialized', 'ok');
refreshDisks();
} catch (error) {
toast('Network error', 'error');
}
}
document.getElementById('diskGrid').addEventListener('click', (event) => {
const button = event.target.closest('button[data-action]');
if (!button) return;
const action = button.dataset.action;
if (action === 'start-copy') startCopy(button.dataset.diskId, button.dataset.mode || 'add');
if (action === 'cancel-copy') cancelCopy(button.dataset.diskId);
if (action === 'init-disk') initDisk(button.dataset.mountPath);
});
refreshDisks();
setInterval(refreshDisks, 5000);
</script> </script>
{{end}} {{end}}

View File

@@ -1,5 +1,5 @@
{{define "layout"}}<!DOCTYPE html> {{define "layout"}}<!DOCTYPE html>
<html lang="ru"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -12,7 +12,7 @@
<h1>🎵 Jukebox Maker</h1> <h1>🎵 Jukebox Maker</h1>
<nav class="header-nav"> <nav class="header-nav">
<a href="/" class="header-action {{if eq .Page "dashboard"}}active{{end}}">Dashboard</a> <a href="/" class="header-action {{if eq .Page "dashboard"}}active{{end}}">Dashboard</a>
<a href="/settings" class="header-action {{if eq .Page "settings"}}active{{end}}">Настройки</a> <a href="/settings" class="header-action {{if eq .Page "settings"}}active{{end}}">Settings</a>
</nav> </nav>
</header> </header>
@@ -20,6 +20,10 @@
{{template "content" .}} {{template "content" .}}
</main> </main>
<footer class="page-footer">
<span>Version {{.Version}}</span>
</footer>
<div class="toast-container" id="toastContainer"></div> <div class="toast-container" id="toastContainer"></div>
<script> <script>
@@ -33,10 +37,10 @@ function toast(msg, type) {
} }
function fmtBytes(b) { function fmtBytes(b) {
if (!b) return '—'; if (!b) return '—';
if (b >= 1e12) return (b/1e12).toFixed(1) + ' ТБ'; if (b >= 1e12) return (b/1e12).toFixed(1) + ' TB';
if (b >= 1e9) return (b/1e9).toFixed(1) + ' ГБ'; if (b >= 1e9) return (b/1e9).toFixed(1) + ' GB';
if (b >= 1e6) return (b/1e6).toFixed(1) + ' МБ'; if (b >= 1e6) return (b/1e6).toFixed(1) + ' MB';
return (b/1e3).toFixed(0) + ' КБ'; return (b/1e3).toFixed(0) + ' KB';
} }
</script> </script>
</body> </body>

View File

@@ -2,100 +2,216 @@
<form id="settingsForm" onsubmit="saveSettings(event)"> <form id="settingsForm" onsubmit="saveSettings(event)">
<section class="panel"> <section class="panel">
<h2>Источники копирования</h2> <h2>Copy Sources</h2>
<div class="source-list" id="sourceList"> <div class="panel-body">
<div class="text-muted" style="padding:12px 16px">Загрузка…</div> <div class="form-hint">Select top-level folders or expand branches and choose individual nested directories.</div>
</div>
<div class="source-list">
<div class="source-tree" id="sourceTree">
<div class="text-muted source-tree-empty">Loading...</div>
</div>
</div> </div>
<div class="btn-row"> <div class="btn-row">
<button type="button" class="button-secondary button-sm" onclick="loadSources()">↻ Обновить список</button> <button type="button" class="button-secondary button-sm" onclick="reloadSourceTree()">Refresh list</button>
</div> </div>
</section> </section>
<section class="panel"> <section class="panel">
<h2>Параметры копирования</h2> <h2>Copy Settings</h2>
<div class="form-body"> <div class="form-body">
<div class="form-group"> <div class="form-group">
<label class="form-label" for="reserveGB">Оставить свободным на диске (ГБ)</label> <label class="form-label" for="reserveGB">Reserved free space on disk (GB)</label>
<input class="form-input" type="number" id="reserveGB" min="0" max="1000" step="0.5" value="2"> <input class="form-input" type="number" id="reserveGB" min="0" max="1000" step="0.5" value="2">
<span class="form-hint">Копирование остановится, когда свободного места останется меньше этого значения.</span> <span class="form-hint">Copying will stop when free space falls below this value.</span>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="fileSelectMode">Какие файлы копировать</label> <label class="form-label" for="fileSelectMode">Files to copy</label>
<select class="form-select" id="fileSelectMode" style="width:auto;max-width:420px"> <select class="form-select" id="fileSelectMode" style="width:auto;max-width:420px">
<option value="new">Только новые (не копировавшиеся на этот диск)</option> <option value="new">Only new files not copied to this disk before</option>
<option value="all">Все подряд</option> <option value="all">All matching files</option>
</select> </select>
<span class="form-hint">«Только новые» — пропускает файлы, уже скопированные на данный диск, даже если они были удалены с него (считаются просмотренными).</span> <span class="form-hint">The new-only mode skips files already copied to this disk, even if they were later removed.</span>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="destFolder">Папка назначения на диске</label> <label class="form-label" for="destFolder">Destination folder on disk</label>
<input class="form-input" type="text" id="destFolder" placeholder="media" style="width:200px"> <input class="form-input" type="text" id="destFolder" placeholder="media" style="width:200px">
<span class="form-hint">Подпапка на диске куда копировать файлы. Структура источника воспроизводится внутри неё. По умолчанию: <code>media</code>.</span> <span class="form-hint">Files will be copied into this subfolder while preserving the selected source structure. The disk root and <code>.jukebox</code> are never allowed here.</span>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="overwriteMode">Режим записи</label> <label class="form-label" for="overwriteMode">Default write mode</label>
<select class="form-select" id="overwriteMode" style="width:auto;max-width:420px"> <select class="form-select" id="overwriteMode" style="width:auto;max-width:420px">
<option value="skip">Пропустить существующие файлы</option> <option value="skip">Keep existing files</option>
<option value="delete">Удалить папку назначения и перезаписать заново</option> <option value="delete">Replace destination folder contents</option>
</select> </select>
<span class="form-hint">«Удалить и перезаписать» — удаляет папку назначения на диске, затем копирует заново.</span> <span class="form-hint">This is used for automatic copy runs. Manual dashboard actions can override it.</span>
</div> </div>
</div> </div>
</section> </section>
<section class="panel"> <section class="panel">
<h2>Автоматизация</h2> <h2>Automation</h2>
<div class="form-body"> <div class="form-body">
<div class="form-group"> <div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer"> <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="autoCopy" style="width:15px;height:15px;accent-color:var(--accent)"> <input type="checkbox" id="autoCopy" style="width:15px;height:15px;accent-color:var(--accent)">
<span class="form-label" style="margin:0">Автоматическое копирование</span> <span class="form-label" style="margin:0">Automatic copy</span>
</label> </label>
<span class="form-hint">При обнаружении знакомого накопителя копирование запустится автоматически.</span> <span class="form-hint">Start copying automatically when a known disk is detected.</span>
</div> </div>
</div> </div>
</section> </section>
<div style="display:flex;gap:8px;margin-bottom:24px"> <div style="display:flex;gap:8px;margin-bottom:24px">
<button type="submit" class="button-primary">Сохранить настройки</button> <button type="submit" class="button-primary">Save settings</button>
<button type="button" class="button-secondary" onclick="loadSettings()">Сбросить</button> <button type="button" class="button-secondary" onclick="loadSettings()">Reset</button>
</div> </div>
</form> </form>
<script> <script>
let allSources = []; const sourceTree = new Map();
let enabledSources = {}; const expandedNodes = new Set();
const loadingNodes = new Set();
let sourceConfig = {};
function escapeHTML(value) {
return String(value || '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char]));
}
function pathDepth(path) {
return path ? path.split('/').length : 0;
}
function parentPath(path) {
if (!path || !path.includes('/')) return '';
return path.slice(0, path.lastIndexOf('/'));
}
function effectiveSourceState(path) {
let current = path;
while (true) {
if (Object.prototype.hasOwnProperty.call(sourceConfig, current)) {
return sourceConfig[current];
}
if (!current) return true;
current = parentPath(current);
}
}
function collectSourcesForSave() {
const items = [];
const seen = new Set();
const roots = sourceTree.get('') || [];
for (const item of roots) {
items.push({ path: item.path, enabled: effectiveSourceState(item.path) });
seen.add(item.path);
}
Object.entries(sourceConfig).forEach(([path, enabled]) => {
if (seen.has(path)) return;
items.push({ path, enabled });
});
return items.sort((a, b) => a.path.localeCompare(b.path));
}
async function loadSourceChildren(path = '') {
if (loadingNodes.has(path)) return;
loadingNodes.add(path);
renderSources();
async function loadSources() {
try { try {
const r = await fetch('/api/sources'); const query = path ? '?path=' + encodeURIComponent(path) : '';
if (!r.ok) return; const response = await fetch('/api/sources' + query);
const d = await r.json(); if (!response.ok) return;
allSources = d.items || []; const payload = await response.json();
sourceTree.set(path, payload.items || []);
} catch (error) {
} finally {
loadingNodes.delete(path);
renderSources(); renderSources();
} catch(e) {} }
}
async function ensureExpanded(path) {
expandedNodes.add(path);
if (!sourceTree.has(path)) {
await loadSourceChildren(path);
return;
}
renderSources();
}
function toggleSource(path, checked) {
sourceConfig[path] = checked;
renderSources();
}
function renderSourceNodes(parent = '') {
const items = sourceTree.get(parent) || [];
return items.map((item) => {
const checked = effectiveSourceState(item.path);
const expanded = expandedNodes.has(item.path);
const childrenKnown = sourceTree.has(item.path);
const children = childrenKnown ? sourceTree.get(item.path) : [];
const hasChildren = !childrenKnown || children.length > 0;
const pad = 16 + pathDepth(item.path) * 20;
return `
<div class="source-node">
<div class="source-row" style="padding-left:${pad}px">
<button
type="button"
class="source-toggle ${hasChildren ? '' : 'source-toggle-empty'}"
data-action="toggle-expand"
data-path="${escapeHTML(item.path)}"
${hasChildren ? '' : 'tabindex="-1" aria-hidden="true"'}
>${expanded ? '▾' : '▸'}</button>
<input class="source-check" type="checkbox" data-action="toggle-check" data-path="${escapeHTML(item.path)}" ${checked ? 'checked' : ''}>
<div class="source-label">
<span class="source-item-name">${escapeHTML(item.name)}</span>
<span class="source-item-path">/media/${escapeHTML(item.path)}</span>
</div>
</div>
${expanded && loadingNodes.has(item.path) ? '<div class="source-loading">Loading...</div>' : ''}
${expanded && childrenKnown && children.length ? `<div class="source-children">${renderSourceNodes(item.path)}</div>` : ''}
</div>
`;
}).join('');
} }
function renderSources() { function renderSources() {
const el = document.getElementById('sourceList'); const el = document.getElementById('sourceTree');
if (!allSources.length) { const roots = sourceTree.get('');
el.innerHTML = '<div class="text-muted" style="padding:12px 16px">Папки в /media не найдены.</div>';
if (loadingNodes.has('') && !roots) {
el.innerHTML = '<div class="text-muted source-tree-empty">Loading...</div>';
return; return;
} }
el.innerHTML = allSources.map(path => { if (!roots || !roots.length) {
const checked = enabledSources[path] !== false; el.innerHTML = '<div class="text-muted source-tree-empty">No folders found in /media.</div>';
return `<label class="source-item"> return;
<input type="checkbox" data-source="${path}" ${checked ? 'checked' : ''}> }
<span class="source-item-name">${path}</span>
<span class="source-item-path">/media/${path}</span> el.innerHTML = renderSourceNodes('');
</label>`; }
}).join('');
async function reloadSourceTree() {
sourceTree.clear();
expandedNodes.clear();
await loadSourceChildren('');
} }
async function loadSettings() { async function loadSettings() {
@@ -108,39 +224,65 @@ async function loadSettings() {
document.getElementById('fileSelectMode').value = cfg.file_select_mode || 'new'; document.getElementById('fileSelectMode').value = cfg.file_select_mode || 'new';
document.getElementById('overwriteMode').value = cfg.overwrite_mode || 'skip'; document.getElementById('overwriteMode').value = cfg.overwrite_mode || 'skip';
document.getElementById('autoCopy').checked = !!cfg.auto_copy; document.getElementById('autoCopy').checked = !!cfg.auto_copy;
enabledSources = {};
(cfg.sources || []).forEach(s => { enabledSources[s.path] = s.enabled; }); sourceConfig = {};
(cfg.sources || []).forEach((source) => {
sourceConfig[source.path] = !!source.enabled;
});
renderSources(); renderSources();
} catch(e) {} } catch (error) {}
} }
async function saveSettings(e) { async function saveSettings(event) {
e.preventDefault(); event.preventDefault();
const checkboxes = document.querySelectorAll('[data-source]');
const sources = Array.from(checkboxes).map(cb => ({ path: cb.dataset.source, enabled: cb.checked }));
Object.keys(enabledSources).forEach(path => {
if (!sources.find(s => s.path === path)) sources.push({ path, enabled: false });
});
const body = { const body = {
reserve_free_gb: parseFloat(document.getElementById('reserveGB').value) || 2, reserve_free_gb: parseFloat(document.getElementById('reserveGB').value) || 2,
dest_folder: document.getElementById('destFolder').value.trim() || 'media', dest_folder: document.getElementById('destFolder').value.trim() || 'media',
file_select_mode: document.getElementById('fileSelectMode').value, file_select_mode: document.getElementById('fileSelectMode').value,
overwrite_mode: document.getElementById('overwriteMode').value, overwrite_mode: document.getElementById('overwriteMode').value,
auto_copy: document.getElementById('autoCopy').checked, auto_copy: document.getElementById('autoCopy').checked,
sources, sources: collectSourcesForSave(),
}; };
try { try {
const r = await fetch('/api/config', { const response = await fetch('/api/config', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (r.ok) { toast('Настройки сохранены', 'ok'); await loadSettings(); } if (response.ok) {
else { const d = await r.json(); toast(d.error || 'Ошибка сохранения', 'error'); } toast('Settings saved', 'ok');
} catch(e) { toast('Ошибка связи', 'error'); } await loadSettings();
return;
}
const payload = await response.json();
toast(payload.error || 'Failed to save settings', 'error');
} catch (error) {
toast('Network error', 'error');
}
} }
document.getElementById('sourceTree').addEventListener('click', async (event) => {
const button = event.target.closest('[data-action="toggle-expand"]');
if (!button) return;
const path = button.dataset.path;
if (expandedNodes.has(path)) {
expandedNodes.delete(path);
renderSources();
return;
}
await ensureExpanded(path);
});
document.getElementById('sourceTree').addEventListener('change', (event) => {
const checkbox = event.target.closest('[data-action="toggle-check"]');
if (!checkbox) return;
toggleSource(checkbox.dataset.path, checkbox.checked);
});
loadSettings(); loadSettings();
loadSources(); loadSourceChildren('');
</script> </script>
{{end}} {{end}}