Add multi-disk copy workflow

This commit is contained in:
2026-04-23 22:24:32 +03:00
parent 5b3cb9e393
commit 31bac2b5d8
10 changed files with 466 additions and 202 deletions
+5 -3
View File
@@ -9,8 +9,9 @@ import (
)
func (s *Server) handleCopyStart(w http.ResponseWriter, r *http.Request) {
diskInfo := s.deps.Watcher.CurrentDisk()
if diskInfo.State != disk.DiskKnown {
diskID := r.PathValue("diskID")
diskInfo, ok := s.deps.Watcher.DiskByID(diskID)
if !ok || diskInfo.State != disk.DiskKnown {
jsonErr(w, http.StatusUnprocessableEntity, "no known disk connected")
return
}
@@ -54,7 +55,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})
}
+50 -11
View File
@@ -1,14 +1,13 @@
package api
import (
"encoding/json"
"net/http"
"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"`
@@ -18,17 +17,57 @@ func (s *Server) handleDiskStatus(w http.ResponseWriter, r *http.Request) {
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 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, 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
}
diskID, err := disk.InitDisk(info.MountPath)
if err != nil {
jsonErr(w, http.StatusInternalServerError, "init disk: "+err.Error())
return
}
jsonOK(w, resp)
s.deps.OnDiskInit(info.MountPath, diskID)
s.deps.Watcher.ProbeNow()
jsonOK(w, map[string]string{"disk_id": diskID})
}
+6 -3
View File
@@ -21,6 +21,8 @@ type Deps struct {
Tasks *task.Store
MediaPath string
MountPath string
// OnDiskInit вызывается при ручной инициализации диска через UI.
OnDiskInit func(mountPath, diskID string)
}
type Server struct {
@@ -56,12 +58,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)
}
+29 -15
View File
@@ -31,38 +31,46 @@ 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),
}
}
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) Start(ctx context.Context, 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")
}
@@ -71,23 +79,29 @@ func (c *Copier) Start(ctx context.Context, opts Options) (string, error) {
opts.DestFolder = "media"
}
t := c.tasks.Create("copy")
t := c.tasks.Create("copy", opts.DiskID)
copyCtx, cancel := context.WithCancel(ctx)
c.cancel = cancel
c.cancels[opts.DiskID] = cancel
go c.run(copyCtx, t.ID, opts, database)
return t.ID, 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
+5 -3
View File
@@ -19,6 +19,7 @@ const (
type Task struct {
ID string `json:"id"`
DiskID string `json:"disk_id"`
Type string `json:"type"`
Status Status `json:"status"`
Progress int `json:"progress"`
@@ -43,9 +44,10 @@ 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 {
t := &Task{
ID: uuid.New().String(),
DiskID: diskID,
Type: taskType,
Status: StatusQueued,
CreatedAt: time.Now().UTC(),
@@ -77,11 +79,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
}
+88 -11
View File
@@ -2,6 +2,8 @@ package watcher
import (
"context"
"path/filepath"
"sort"
"sync"
"time"
@@ -10,7 +12,7 @@ import (
type DiskEvent struct {
Info disk.DiskInfo
Prev disk.DiskState
Prev disk.DiskInfo
}
type Handler func(event DiskEvent)
@@ -20,8 +22,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 +31,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 +87,61 @@ 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 {
candidates := []string{root}
if entries, err := filepath.Glob(filepath.Join(root, "*")); err == nil {
for _, path := range entries {
candidates = append(candidates, path)
}
}
disks := make(map[string]disk.DiskInfo)
seen := make(map[string]struct{}, len(candidates))
for _, mountPath := range candidates {
if _, ok := seen[mountPath]; ok {
continue
}
seen[mountPath] = struct{}{}
info, _ := disk.Probe(mountPath)
if info.State == disk.DiskAbsent {
continue
}
disks[mountPath] = info
}
return disks
}