8 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
7c5736b935 copier: rsync, случайный порядок, папка назначения, права 777
- rsync --partial --append-verify для возобновления передачи
- --no-perms --chmod=ugo=rwx — права 777 на диске
- Структура: <mount>/<dest_folder>/<rel path from /media>, default media
- Shuffle файлов перед копированием
- Диск заполняется пока есть место, файлы которые не влезают пропускаются
- Прогресс по количеству обработанных файлов
- rsync добавлен в Dockerfile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:57:57 +03:00
8f36d4e824 copier: rsync с возобновлением, папка назначения, права 777
- Заменить ручное копирование на rsync --partial --append-verify
- Структура на диске: <mount>/<dest_folder>/<rel path from /media>
- dest_folder настраивается (default: media)
- Права на диске: --no-perms --chmod=ugo=rwx
- rsync добавлен в Dockerfile
- Режим "удалить": удаляет только dest_folder, а не весь диск

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:56:26 +03:00
18 changed files with 1516 additions and 364 deletions

7
.dockerignore Normal file
View File

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

View File

@@ -1,16 +1,25 @@
FROM golang:1.25-alpine AS builder
# syntax=docker/dockerfile:1.7
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
WORKDIR /src
ARG VERSION=dev
ARG TARGETOS
ARG TARGETARCH
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 . .
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
FROM alpine:3.19
RUN apk add --no-cache tzdata ca-certificates
RUN apk add --no-cache tzdata ca-certificates rsync
WORKDIR /app
COPY --from=builder /out/jukebox .

View File

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

View File

@@ -2,28 +2,61 @@ package api
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"jukebox_maker/internal/config"
"jukebox_maker/internal/copier"
"jukebox_maker/internal/disk"
)
func (s *Server) handleCopyStart(w http.ResponseWriter, r *http.Request) {
diskInfo := s.deps.Watcher.CurrentDisk()
if diskInfo.State != disk.DiskKnown {
jsonErr(w, http.StatusUnprocessableEntity, "no known disk connected")
diskID := r.PathValue("diskID")
diskInfo, ok := s.deps.Watcher.DiskByID(diskID)
if !ok || diskInfo.State != disk.DiskKnown {
jsonErr(w, http.StatusUnprocessableEntity, "no initialized disk connected")
return
}
cfg := s.deps.Config
var enabledSources []string
hasEnabledSources := false
for _, src := range cfg.Sources {
if src.Enabled {
enabledSources = append(enabledSources, src.Path)
hasEnabledSources = true
break
}
}
if len(enabledSources) == 0 {
jsonErr(w, http.StatusUnprocessableEntity, "no sources enabled")
if !hasEnabledSources {
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
}
@@ -31,9 +64,10 @@ func (s *Server) handleCopyStart(w http.ResponseWriter, r *http.Request) {
DiskID: diskInfo.DiskID,
MountPath: diskInfo.MountPath,
MediaPath: s.deps.MediaPath,
EnabledSources: enabledSources,
DestFolder: cfg.DestFolder,
SourceRules: cfg.Sources,
ReserveFreeGB: cfg.ReserveFreeGB,
OverwriteMode: cfg.OverwriteMode,
OverwriteMode: overwriteMode,
FileSelectMode: cfg.FileSelectMode,
}
@@ -53,7 +87,8 @@ func (s *Server) handleCopyStart(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})
}

View File

@@ -1,34 +1,82 @@
package api
import (
"encoding/json"
"net/http"
"time"
"jukebox_maker/internal/disk"
)
func (s *Server) handleDiskStatus(w http.ResponseWriter, r *http.Request) {
info := s.deps.Watcher.CurrentDisk()
type response struct {
State disk.DiskState `json:"state"`
DiskID string `json:"disk_id"`
TotalBytes int64 `json:"total_bytes"`
FreeBytes int64 `json:"free_bytes"`
MountPath string `json:"mount_path"`
LastCopiedAt string `json:"last_copied_at,omitempty"`
ActiveTaskID string `json:"active_task_id,omitempty"`
}
resp := response{
State: info.State,
DiskID: info.DiskID,
TotalBytes: info.TotalBytes,
FreeBytes: info.FreeBytes,
MountPath: info.MountPath,
disks := s.deps.Watcher.ListDisks()
resp := make([]response, 0, len(disks))
for _, info := range disks {
item := response{
State: info.State,
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 {
resp.ActiveTaskID = t.ID
}
jsonOK(w, resp)
jsonOK(w, map[string]any{"items": resp})
}
func (s *Server) handleDiskInit(w http.ResponseWriter, r *http.Request) {
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
import (
"errors"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
)
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 {
jsonOK(w, map[string][]string{"items": {}})
jsonErr(w, http.StatusBadRequest, err.Error())
return
}
var items []string
for _, e := range entries {
if e.IsDir() && e.Name()[0] != '.' {
items = append(items, e.Name())
}
}
if items == nil {
items = []string{}
absPath := s.deps.MediaPath
if relPath != "" {
absPath = filepath.Join(absPath, relPath)
}
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 {
Config *config.Config
ConfigPath string
Version string
Watcher *watcher.Watcher
Copier *copier.Copier
Tasks *task.Store
MediaPath string
MountPath string
// OnDiskInit вызывается при ручной инициализации диска через UI.
OnDiskInit func(mountPath, diskID string)
}
type Server struct {
@@ -56,12 +59,13 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /settings", s.handleSettings)
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/config", s.handleGetConfig)
s.mux.HandleFunc("PUT /api/config", s.handlePutConfig)
s.mux.HandleFunc("POST /api/copy/start", s.handleCopyStart)
s.mux.HandleFunc("POST /api/copy/cancel", s.handleCopyCancel)
s.mux.HandleFunc("POST /api/disks/{diskID}/copy/start", s.handleCopyStart)
s.mux.HandleFunc("POST /api/disks/{diskID}/copy/cancel", s.handleCopyCancel)
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) {
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) {
@@ -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) {
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)
}
}

View File

@@ -5,12 +5,17 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"jukebox_maker/internal/disk"
)
type OverwriteMode string
type FileSelectMode string
const (
DefaultDestFolder = "media"
OverwriteSkip OverwriteMode = "skip"
OverwriteDelete OverwriteMode = "delete"
@@ -25,15 +30,19 @@ type SourceFolder struct {
type Config struct {
ReserveFreeGB float64 `json:"reserve_free_gb"`
DestFolder string `json:"dest_folder"`
Sources []SourceFolder `json:"sources"`
OverwriteMode OverwriteMode `json:"overwrite_mode"`
FileSelectMode FileSelectMode `json:"file_select_mode"`
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 {
return Config{
ReserveFreeGB: 2.0,
DestFolder: DefaultDestFolder,
OverwriteMode: OverwriteSkip,
FileSelectMode: SelectNew,
AutoCopy: false,
@@ -53,6 +62,11 @@ func Load(path string) (*Config, error) {
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}
if destFolder, err := NormalizeDestFolder(cfg.DestFolder); err == nil {
cfg.DestFolder = destFolder
} else {
cfg.DestFolder = defaults().DestFolder
}
return &cfg, nil
}
@@ -75,6 +89,9 @@ func (c *Config) Validate() error {
if c.ReserveFreeGB < 0 {
return errors.New("reserve_free_gb must be >= 0")
}
if _, err := NormalizeDestFolder(c.DestFolder); err != nil {
return err
}
switch c.OverwriteMode {
case OverwriteSkip, OverwriteDelete:
default:
@@ -87,3 +104,26 @@ func (c *Config) Validate() error {
}
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,12 +2,17 @@ package copier
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand/v2"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"jukebox_maker/internal/config"
"jukebox_maker/internal/db"
@@ -19,7 +24,8 @@ type Options struct {
DiskID string
MountPath string
MediaPath string
EnabledSources []string
DestFolder string // subfolder on disk, default "media"
SourceRules []config.SourceFolder
ReserveFreeGB float64
OverwriteMode config.OverwriteMode
FileSelectMode config.FileSelectMode
@@ -28,79 +34,179 @@ type Options struct {
type Copier struct {
tasks *task.Store
mu sync.Mutex
cancel context.CancelFunc
mu sync.Mutex
cancels map[string]context.CancelFunc
dbMu sync.RWMutex
db *db.DB
dbs map[string]*db.DB
}
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),
}
}
// SetDB replaces the active disk database (called when a disk connects or disconnects).
func (c *Copier) SetDB(d *db.DB) {
func (c *Copier) SetDB(diskID string, d *db.DB) {
c.dbMu.Lock()
c.db = d
if d == nil {
delete(c.dbs, diskID)
} else {
c.dbs[diskID] = d
}
c.dbMu.Unlock()
}
func (c *Copier) getDB() *db.DB {
func (c *Copier) getDB(diskID string) *db.DB {
c.dbMu.RLock()
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) {
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()
defer c.mu.Unlock()
if _, active := c.tasks.ActiveTask(); active {
if _, active := c.cancels[opts.DiskID]; active {
return "", errors.New("copy already running")
}
database := c.getDB()
database := c.getDB(opts.DiskID)
if database == nil {
return "", errors.New("no disk database available")
}
t := c.tasks.Create("copy")
copyCtx, cancel := context.WithCancel(ctx)
c.cancel = cancel
if opts.DestFolder == "" {
opts.DestFolder = config.DefaultDestFolder
}
destFolder, err := config.NormalizeDestFolder(opts.DestFolder)
if err != nil {
destFolder = config.DefaultDestFolder
}
opts.DestFolder = destFolder
go c.run(copyCtx, t.ID, opts, database)
return t.ID, nil
_, 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")
}
var taskID string
if existingTaskID == "" {
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
}
}
}
copyCtx, cancel := context.WithCancel(ctx)
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()
defer c.mu.Unlock()
if c.cancel != nil {
c.cancel()
if cancel, ok := c.cancels[diskID]; ok {
cancel()
}
}
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) {
c.tasks.Update(taskID, func(t *task.Task) {
t.Status = s
t.Message = msg
t.Progress = prog
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
}
fail := func(err error) {
c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusFailed
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)
if opts.OverwriteMode == config.OverwriteDelete {
setStatus(task.StatusRunning, "Удаление данных с диска…", 0)
if err := deleteOurData(opts.MountPath); err != nil {
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 {
fail(err)
return
}
@@ -108,7 +214,15 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
var copiedPaths map[string]struct{}
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
copiedPaths, err = database.CopiedPaths(opts.DiskID)
if err != nil {
@@ -117,17 +231,28 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
}
}
setStatus(task.StatusRunning, "Сканирование источников…", 0)
files, err := buildFileList(opts.MediaPath, opts.EnabledSources, copiedPaths)
c.tasks.Update(taskID, func(t *task.Task) {
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 {
fail(err)
return
}
if len(files) == 0 {
setStatus(task.StatusSuccess, "Нет новых файлов для копирования.", 100)
setStatus(task.StatusSuccess, "No files to copy.", 100)
return
}
// случайный порядок — выбираем что копировать до начала копирования
rand.Shuffle(len(files), func(i, j int) { files[i], files[j] = files[j], files[i] })
_, free, err := disk.DiskUsage(opts.MountPath)
if err != nil {
fail(err)
@@ -136,19 +261,33 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
reserveBytes := int64(opts.ReserveFreeGB * 1e9)
available := free - reserveBytes
if available <= 0 {
setStatus(task.StatusSuccess, "Недостаточно свободного места на диске.", 100)
setStatus(task.StatusFailed, "Free space is below the reserved threshold.", 100)
return
}
// суммарный объём для прогресса (всех файлов в списке)
var totalBytes int64
for _, f := range files {
totalBytes += f.size
}
total := len(files)
copied := 0
var doneBytes int64
startTime := time.Now()
for i, f := range files {
select {
case <-ctx.Done():
c.tasks.Update(taskID, func(t *task.Task) {
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
default:
}
@@ -157,23 +296,50 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
continue
}
msg := fmt.Sprintf("Копирование %s (%d/%d)", filepath.Base(f.srcAbs), i+1, total)
prog := int(float64(i+1) / float64(total) * 100)
setStatus(task.StatusRunning, msg, prog)
elapsed := time.Since(startTime).Seconds()
var speedBPS, etaSec int64
if elapsed > 0 && doneBytes > 0 {
speedBPS = int64(float64(doneBytes) / elapsed)
remaining := totalBytes - doneBytes
if speedBPS > 0 {
etaSec = remaining / speedBPS
}
}
dstAbs := filepath.Join(opts.MountPath, f.relPath)
if err := copyFile(ctx, f.srcAbs, dstAbs); err != nil {
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)
}
dstAbs := filepath.Join(destRoot, f.relPath)
if err := rsyncFile(ctx, f.srcAbs, dstAbs); err != nil {
if errors.Is(err, context.Canceled) {
c.tasks.Update(taskID, func(t *task.Task) {
t.Status = task.StatusCanceled
t.Message = "Отменено"
t.Message = "Canceled"
t.SpeedBPS = 0
t.ETASec = 0
})
if t, ok := c.tasks.Get(taskID); ok {
_ = database.UpdateTask(*t)
}
return
}
continue
}
available -= f.size
doneBytes += f.size
copied++
_ = database.RecordCopy(db.CopyRecord{
DiskID: opts.DiskID,
@@ -182,24 +348,44 @@ 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 {
srcAbs string
relPath string
relPath string // relative to /media
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
for _, src := range sources {
for _, src := range roots {
dir := filepath.Join(mediaPath, src)
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
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
}
rel, _ := filepath.Rel(mediaPath, path)
rel = filepath.ToSlash(rel)
if !isPathEnabled(rel, ruleMap) {
return nil
}
if _, skipped := skip[rel]; skipped {
return nil
}
@@ -217,64 +403,91 @@ func buildFileList(mediaPath string, sources []string, skip map[string]struct{})
return result, nil
}
func deleteOurData(mountPath string) error {
entries, err := os.ReadDir(mountPath)
if err != nil {
return err
}
for _, e := range entries {
if e.Name() == ".jukebox" {
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 err := os.RemoveAll(filepath.Join(mountPath, e.Name())); err != nil {
return err
if src == ".." || strings.HasPrefix(src, "../") {
continue
}
ruleMap[src] = rule.Enabled
}
return nil
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 copyFile(ctx context.Context, src, dst string) error {
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.
// --partial keeps partial files on interruption.
// --append-verify resumes partial transfers and verifies checksums.
func rsyncFile(ctx context.Context, src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
in, err := os.Open(src)
cmd := exec.CommandContext(ctx, "rsync",
"--partial",
"--append-verify",
"--times",
"--no-perms",
"--no-owner",
"--no-group",
"--chmod=ugo=rwx",
src, dst,
)
out, err := cmd.CombinedOutput()
if err != nil {
return err
}
defer in.Close()
tmp := dst + ".juketmp"
out, err := os.Create(tmp)
if err != nil {
return err
}
buf := make([]byte, 512*1024)
for {
select {
case <-ctx.Done():
out.Close()
os.Remove(tmp)
if ctx.Err() != nil {
return ctx.Err()
default:
}
n, readErr := in.Read(buf)
if n > 0 {
if _, werr := out.Write(buf[:n]); werr != nil {
out.Close()
os.Remove(tmp)
return werr
}
}
if errors.Is(readErr, io.EOF) {
break
}
if readErr != nil {
out.Close()
os.Remove(tmp)
return readErr
}
return fmt.Errorf("rsync: %w: %s", err, out)
}
out.Close()
return os.Rename(tmp, dst)
return nil
}

View File

@@ -2,8 +2,11 @@ package db
import (
"database/sql"
"encoding/json"
"time"
"jukebox_maker/internal/task"
_ "modernc.org/sqlite"
)
@@ -18,6 +21,11 @@ type CopyRecord struct {
CopiedAt time.Time
}
type TaskRecord struct {
Task task.Task
Payload json.RawMessage
}
func Open(path string) (*DB, error) {
conn, err := sql.Open("sqlite", path+"?_journal=WAL&_timeout=5000")
if err != nil {
@@ -47,6 +55,26 @@ func (d *DB) migrate() error {
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_copy_history_disk_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
}
@@ -65,11 +93,26 @@ func (d *DB) RecordCopy(rec CopyRecord) error {
if t.IsZero() {
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 (?,?,?,?)`,
rec.DiskID, rec.SourcePath, rec.FileSize, t.Format(time.RFC3339),
)
return err
); err != nil {
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) {
@@ -90,3 +133,114 @@ func (d *DB) CopiedPaths(diskID string) (map[string]struct{}, error) {
}
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"`
}
const markerDir = ".jukebox"
const MarkerDir = ".jukebox"
const idFile = "disk.id"
func Probe(mountPath string) (DiskInfo, error) {
info := DiskInfo{MountPath: mountPath, State: DiskAbsent}
entries, err := os.ReadDir(mountPath)
if err != nil || len(entries) == 0 {
if _, err := os.ReadDir(mountPath); err != nil {
return info, nil
}
@@ -44,7 +43,7 @@ func Probe(mountPath string) (DiskInfo, error) {
info.TotalBytes = total
info.FreeBytes = free
idPath := filepath.Join(mountPath, markerDir, idFile)
idPath := filepath.Join(mountPath, MarkerDir, idFile)
data, err := os.ReadFile(idPath)
if errors.Is(err, os.ErrNotExist) {
info.State = DiskForeign
@@ -60,8 +59,45 @@ func Probe(mountPath string) (DiskInfo, error) {
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) {
dir := filepath.Join(mountPath, markerDir)
dir := filepath.Join(mountPath, MarkerDir)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
@@ -74,7 +110,7 @@ func InitDisk(mountPath string) (string, error) {
}
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) {

View File

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

View File

@@ -2,6 +2,10 @@ package watcher
import (
"context"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
@@ -10,7 +14,7 @@ import (
type DiskEvent struct {
Info disk.DiskInfo
Prev disk.DiskState
Prev disk.DiskInfo
}
type Handler func(event DiskEvent)
@@ -20,8 +24,8 @@ type Watcher struct {
interval time.Duration
handler Handler
mu sync.RWMutex
current disk.DiskInfo
mu sync.RWMutex
disks map[string]disk.DiskInfo
}
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,
interval: interval,
handler: handler,
disks: make(map[string]disk.DiskInfo),
}
}
func (w *Watcher) CurrentDisk() disk.DiskInfo {
func (w *Watcher) ListDisks() []disk.DiskInfo {
w.mu.RLock()
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) {
@@ -56,15 +89,69 @@ func (w *Watcher) Run(ctx context.Context) {
}
func (w *Watcher) probe() {
info, _ := disk.Probe(w.mountPath)
next := discoverDisks(w.mountPath)
w.mu.Lock()
prev := w.current.State
changed := prev != info.State
w.current = info
prev := w.disks
w.disks = next
w.mu.Unlock()
if changed && w.handler != nil {
w.handler(DiskEvent{Info: info, Prev: prev})
if w.handler == nil {
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; }
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_VERSION=$(git -C "${ROOT_DIR}" describe --tags --always 2>/dev/null || echo dev)
ask() {
# $1=varname $2=prompt $3=default
@@ -46,6 +48,12 @@ else
ask IMAGE "Image" ""
fi
echo "checking Go build"
(
cd "${ROOT_DIR}"
go build ./...
)
if [ -n "${IMAGE}" ]; then
# multi-arch build + push
docker buildx version >/dev/null 2>&1 || die "docker buildx not available"
@@ -66,6 +74,7 @@ if [ -n "${IMAGE}" ]; then
docker buildx build \
--platform "${PLATFORMS}" \
--file "${ROOT_DIR}/Dockerfile" \
--build-arg "VERSION=${DEFAULT_VERSION}" \
-t "${IMAGE}:${IMAGE_TAG}" \
-t "${IMAGE}:latest" \
--push \
@@ -80,6 +89,7 @@ else
echo "building locally (no push)"
docker build \
--file "${ROOT_DIR}/Dockerfile" \
--build-arg "VERSION=${DEFAULT_VERSION}" \
-t "jukebox-maker:${IMAGE_TAG}" \
-t "jukebox-maker:latest" \
"${ROOT_DIR}"

View File

@@ -69,6 +69,14 @@ a:hover { text-decoration: underline; }
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 {
margin-bottom: 24px;
@@ -89,6 +97,24 @@ a:hover { text-decoration: underline; }
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 {
width: 100%;
@@ -241,6 +267,69 @@ a:hover { text-decoration: underline; }
/* Checkbox list */
.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 {
display: flex;
align-items: center;
@@ -325,5 +414,8 @@ a:hover { text-decoration: underline; }
@media (max-width: 720px) {
.page-header { flex-wrap: wrap; padding: 12px 16px; }
.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; }
.btn-row { flex-wrap: wrap; }
}

View File

@@ -1,137 +1,260 @@
{{define "content"}}
<section class="panel">
<h2>Накопитель</h2>
<table class="kv-table">
<tbody>
<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>
<h2>Disks</h2>
<div class="panel-body">
<div id="diskSummary" class="text-muted">Loading disks...</div>
</div>
</section>
<div class="btn-row" style="background:transparent;border:none;padding:0;margin-bottom:24px">
<button class="button-primary" id="btnStart" onclick="startCopy()" disabled>▶ Запустить копирование</button>
<button class="button-danger hidden" id="btnCancel" onclick="cancelCopy()">✕ Отменить</button>
</div>
<div class="disk-grid" id="diskGrid"></div>
<script>
let pollInterval = null;
let activeTaskId = null;
let disks = [];
const taskState = new Map();
const taskPollers = new Map();
async function refreshDisk() {
try {
const r = await fetch('/api/disk');
if (!r.ok) return;
const d = await r.json();
const labels = { absent: 'Не подключён', foreign: 'Незнакомый диск', known: 'Диск подключён' };
const cls = { absent: 'badge-unknown', foreign: 'badge-warn', known: 'badge-ok' };
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 escapeHTML(value) {
return String(value || '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char]));
}
function startTaskPoll(id) { stopTaskPoll(); pollInterval = setInterval(() => pollTask(id), 1500); }
function stopTaskPoll() { if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } }
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) {}
function diskKey(disk) {
return disk.disk_id || disk.mount_path;
}
async function startCopy() {
document.getElementById('btnStart').disabled = true;
function badgeClass(state) {
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 {
const r = await fetch('/api/copy/start', { method: 'POST' });
const d = await r.json();
if (!r.ok) {
toast(d.error || 'Ошибка запуска', 'error');
document.getElementById('btnStart').disabled = false;
const response = await fetch('/api/disks');
if (!response.ok) return;
const payload = await response.json();
disks = payload.items || [];
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;
}
activeTaskId = d.task_id;
document.getElementById('btnStart').classList.add('hidden');
document.getElementById('btnCancel').classList.remove('hidden');
document.getElementById('progressPanel').classList.remove('hidden');
document.getElementById('progressFill').style.width = '0%';
document.getElementById('progressMsg').textContent = 'Подготовка…';
startTaskPoll(activeTaskId);
} catch(e) {
toast('Ошибка связи', 'error');
document.getElementById('btnStart').disabled = false;
startTaskPoll(payload.task_id);
refreshDisks();
} catch (error) {
toast('Network error', 'error');
}
}
async function cancelCopy() {
try { await fetch('/api/copy/cancel', { method: 'POST' }); toast('Отмена…', 'ok'); } catch(e) {}
async function cancelCopy(diskID) {
try {
await fetch('/api/disks/' + encodeURIComponent(diskID) + '/copy/cancel', { method: 'POST' });
toast('Canceling...', 'ok');
} catch (error) {
toast('Network error', 'error');
}
}
refreshDisk();
setInterval(refreshDisk, 5000);
async function initDisk(mountPath) {
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>
{{end}}

View File

@@ -1,5 +1,5 @@
{{define "layout"}}<!DOCTYPE html>
<html lang="ru">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -12,7 +12,7 @@
<h1>🎵 Jukebox Maker</h1>
<nav class="header-nav">
<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>
</header>
@@ -20,6 +20,10 @@
{{template "content" .}}
</main>
<footer class="page-footer">
<span>Version {{.Version}}</span>
</footer>
<div class="toast-container" id="toastContainer"></div>
<script>
@@ -33,10 +37,10 @@ function toast(msg, type) {
}
function fmtBytes(b) {
if (!b) return '—';
if (b >= 1e12) return (b/1e12).toFixed(1) + ' ТБ';
if (b >= 1e9) return (b/1e9).toFixed(1) + ' ГБ';
if (b >= 1e6) return (b/1e6).toFixed(1) + ' МБ';
return (b/1e3).toFixed(0) + ' КБ';
if (b >= 1e12) return (b/1e12).toFixed(1) + ' TB';
if (b >= 1e9) return (b/1e9).toFixed(1) + ' GB';
if (b >= 1e6) return (b/1e6).toFixed(1) + ' MB';
return (b/1e3).toFixed(0) + ' KB';
}
</script>
</body>

View File

@@ -2,94 +2,216 @@
<form id="settingsForm" onsubmit="saveSettings(event)">
<section class="panel">
<h2>Источники копирования</h2>
<div class="source-list" id="sourceList">
<div class="text-muted" style="padding:12px 16px">Загрузка…</div>
<h2>Copy Sources</h2>
<div class="panel-body">
<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 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>
</section>
<section class="panel">
<h2>Параметры копирования</h2>
<h2>Copy Settings</h2>
<div class="form-body">
<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">
<span class="form-hint">Копирование остановится, когда свободного места останется меньше этого значения.</span>
<span class="form-hint">Copying will stop when free space falls below this value.</span>
</div>
<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">
<option value="new">Только новые (не копировавшиеся на этот диск)</option>
<option value="all">Все подряд</option>
<option value="new">Only new files not copied to this disk before</option>
<option value="all">All matching files</option>
</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 class="form-group">
<label class="form-label" for="overwriteMode">Режим записи</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">
<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 class="form-group">
<label class="form-label" for="overwriteMode">Default write mode</label>
<select class="form-select" id="overwriteMode" style="width:auto;max-width:420px">
<option value="skip">Пропустить существующие файлы</option>
<option value="delete">Удалить наши данные с диска и перезаписать заново</option>
<option value="skip">Keep existing files</option>
<option value="delete">Replace destination folder contents</option>
</select>
<span class="form-hint">«Удалить и перезаписать» — удаляет с диска всё кроме папки .jukebox, затем копирует заново.</span>
<span class="form-hint">This is used for automatic copy runs. Manual dashboard actions can override it.</span>
</div>
</div>
</section>
<section class="panel">
<h2>Автоматизация</h2>
<h2>Automation</h2>
<div class="form-body">
<div class="form-group">
<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)">
<span class="form-label" style="margin:0">Автоматическое копирование</span>
<span class="form-label" style="margin:0">Automatic copy</span>
</label>
<span class="form-hint">При обнаружении знакомого накопителя копирование запустится автоматически.</span>
<span class="form-hint">Start copying automatically when a known disk is detected.</span>
</div>
</div>
</section>
<div style="display:flex;gap:8px;margin-bottom:24px">
<button type="submit" class="button-primary">Сохранить настройки</button>
<button type="button" class="button-secondary" onclick="loadSettings()">Сбросить</button>
<button type="submit" class="button-primary">Save settings</button>
<button type="button" class="button-secondary" onclick="loadSettings()">Reset</button>
</div>
</form>
<script>
let allSources = [];
let enabledSources = {};
const sourceTree = new Map();
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 {
const r = await fetch('/api/sources');
if (!r.ok) return;
const d = await r.json();
allSources = d.items || [];
const query = path ? '?path=' + encodeURIComponent(path) : '';
const response = await fetch('/api/sources' + query);
if (!response.ok) return;
const payload = await response.json();
sourceTree.set(path, payload.items || []);
} catch (error) {
} finally {
loadingNodes.delete(path);
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() {
const el = document.getElementById('sourceList');
if (!allSources.length) {
el.innerHTML = '<div class="text-muted" style="padding:12px 16px">Папки в /media не найдены.</div>';
const el = document.getElementById('sourceTree');
const roots = sourceTree.get('');
if (loadingNodes.has('') && !roots) {
el.innerHTML = '<div class="text-muted source-tree-empty">Loading...</div>';
return;
}
el.innerHTML = allSources.map(path => {
const checked = enabledSources[path] !== false;
return `<label class="source-item">
<input type="checkbox" data-source="${path}" ${checked ? 'checked' : ''}>
<span class="source-item-name">${path}</span>
<span class="source-item-path">/media/${path}</span>
</label>`;
}).join('');
if (!roots || !roots.length) {
el.innerHTML = '<div class="text-muted source-tree-empty">No folders found in /media.</div>';
return;
}
el.innerHTML = renderSourceNodes('');
}
async function reloadSourceTree() {
sourceTree.clear();
expandedNodes.clear();
await loadSourceChildren('');
}
async function loadSettings() {
@@ -98,41 +220,69 @@ async function loadSettings() {
if (!r.ok) return;
const cfg = await r.json();
document.getElementById('reserveGB').value = cfg.reserve_free_gb ?? 2;
document.getElementById('destFolder').value = cfg.dest_folder || 'media';
document.getElementById('fileSelectMode').value = cfg.file_select_mode || 'new';
document.getElementById('overwriteMode').value = cfg.overwrite_mode || 'skip';
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();
} catch(e) {}
} catch (error) {}
}
async function saveSettings(e) {
e.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 });
});
async function saveSettings(event) {
event.preventDefault();
const body = {
reserve_free_gb: parseFloat(document.getElementById('reserveGB').value) || 2,
dest_folder: document.getElementById('destFolder').value.trim() || 'media',
file_select_mode: document.getElementById('fileSelectMode').value,
overwrite_mode: document.getElementById('overwriteMode').value,
auto_copy: document.getElementById('autoCopy').checked,
sources,
sources: collectSourcesForSave(),
};
try {
const r = await fetch('/api/config', {
const response = await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (r.ok) { toast('Настройки сохранены', 'ok'); await loadSettings(); }
else { const d = await r.json(); toast(d.error || 'Ошибка сохранения', 'error'); }
} catch(e) { toast('Ошибка связи', 'error'); }
if (response.ok) {
toast('Settings saved', 'ok');
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();
loadSources();
loadSourceChildren('');
</script>
{{end}}