Add multi-disk copy workflow
This commit is contained in:
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 ©, true
|
||||
}
|
||||
|
||||
+88
-11
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user