Add standalone desktop workflow
This commit is contained in:
@@ -12,6 +12,47 @@ import (
|
||||
"jukebox_maker/internal/disk"
|
||||
)
|
||||
|
||||
func (s *Server) copyOptions(cfg *config.Config, diskInfo disk.DiskInfo, overwriteMode config.OverwriteMode) copier.Options {
|
||||
return copier.Options{
|
||||
DiskID: diskInfo.DiskID,
|
||||
MountPath: diskInfo.MountPath,
|
||||
MediaPath: cfg.MediaPath,
|
||||
DestFolder: cfg.DestFolder,
|
||||
SourceRules: cfg.Sources,
|
||||
ReserveFreeGB: cfg.ReserveFreeGB,
|
||||
OverwriteMode: overwriteMode,
|
||||
FileSelectMode: cfg.FileSelectMode,
|
||||
}
|
||||
}
|
||||
|
||||
func hasEnabledSources(cfg *config.Config) bool {
|
||||
for _, src := range cfg.Sources {
|
||||
if src.Enabled {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func decodeCopyMode(r *http.Request, fallback config.OverwriteMode) (config.OverwriteMode, error) {
|
||||
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) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
switch req.Mode {
|
||||
case "", "add":
|
||||
return config.OverwriteSkip, nil
|
||||
case "replace":
|
||||
return config.OverwriteDelete, nil
|
||||
default:
|
||||
return fallback, errors.New("invalid copy mode")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleCopyStart(w http.ResponseWriter, r *http.Request) {
|
||||
diskID := r.PathValue("diskID")
|
||||
diskInfo, ok := s.deps.Watcher.DiskByID(diskID)
|
||||
@@ -21,26 +62,67 @@ func (s *Server) handleCopyStart(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
cfg := s.deps.Config
|
||||
hasEnabledSources := false
|
||||
for _, src := range cfg.Sources {
|
||||
if src.Enabled {
|
||||
hasEnabledSources = true
|
||||
break
|
||||
}
|
||||
if !hasEnabledSources(cfg) {
|
||||
jsonErr(w, http.StatusUnprocessableEntity, "no source folders selected")
|
||||
return
|
||||
}
|
||||
if !hasEnabledSources {
|
||||
|
||||
overwriteMode, err := decodeCopyMode(r, cfg.OverwriteMode)
|
||||
if err != nil {
|
||||
if err.Error() == "invalid copy mode" {
|
||||
jsonErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
jsonErr(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
reserveBytes := int64(cfg.ReserveFreeGB * 1e9)
|
||||
if diskInfo.FreeBytes <= reserveBytes {
|
||||
jsonErr(w, http.StatusUnprocessableEntity, "free space is below reserve threshold")
|
||||
return
|
||||
}
|
||||
|
||||
opts := s.copyOptions(cfg, diskInfo, overwriteMode)
|
||||
|
||||
taskID, err := s.deps.Copier.Start(context.Background(), opts)
|
||||
if err != nil {
|
||||
switch err.Error() {
|
||||
case "copy already running":
|
||||
jsonErr(w, http.StatusConflict, err.Error())
|
||||
default:
|
||||
jsonErr(w, http.StatusUnprocessableEntity, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
jsonOK(w, map[string]string{"task_id": taskID})
|
||||
}
|
||||
|
||||
func (s *Server) handleCopyStartSelected(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := s.deps.Config
|
||||
if !hasEnabledSources(cfg) {
|
||||
jsonErr(w, http.StatusUnprocessableEntity, "no source folders selected")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Mode string `json:"mode"`
|
||||
MountPath string `json:"mount_path"`
|
||||
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
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
diskInfo, err := s.deps.ProbeDisk(req.MountPath)
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if diskInfo.State != disk.DiskKnown {
|
||||
jsonErr(w, http.StatusUnprocessableEntity, "selected directory is not an initialized jukebox disk")
|
||||
return
|
||||
}
|
||||
|
||||
overwriteMode := cfg.OverwriteMode
|
||||
@@ -60,18 +142,10 @@ func (s *Server) handleCopyStart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
opts := copier.Options{
|
||||
DiskID: diskInfo.DiskID,
|
||||
MountPath: diskInfo.MountPath,
|
||||
MediaPath: s.deps.MediaPath,
|
||||
DestFolder: cfg.DestFolder,
|
||||
SourceRules: cfg.Sources,
|
||||
ReserveFreeGB: cfg.ReserveFreeGB,
|
||||
OverwriteMode: overwriteMode,
|
||||
FileSelectMode: cfg.FileSelectMode,
|
||||
if s.deps.OnDiskInit != nil {
|
||||
s.deps.OnDiskInit(diskInfo.MountPath, diskInfo.DiskID)
|
||||
}
|
||||
|
||||
taskID, err := s.deps.Copier.Start(context.Background(), opts)
|
||||
taskID, err := s.deps.Copier.Start(context.Background(), s.copyOptions(cfg, diskInfo, overwriteMode))
|
||||
if err != nil {
|
||||
switch err.Error() {
|
||||
case "copy already running":
|
||||
|
||||
@@ -8,6 +8,28 @@ import (
|
||||
"jukebox_maker/internal/disk"
|
||||
)
|
||||
|
||||
func (s *Server) diskResponse(info disk.DiskInfo) map[string]any {
|
||||
item := map[string]any{
|
||||
"state": info.State,
|
||||
"disk_id": info.DiskID,
|
||||
"total_bytes": info.TotalBytes,
|
||||
"free_bytes": info.FreeBytes,
|
||||
"mount_path": info.MountPath,
|
||||
}
|
||||
if info.DiskID != "" {
|
||||
if s.deps.OnDiskInit != nil {
|
||||
s.deps.OnDiskInit(info.MountPath, info.DiskID)
|
||||
}
|
||||
if lastCopiedAt, ok, err := s.deps.Copier.LastCopiedAt(info.DiskID); err == nil && ok {
|
||||
item["last_copied_at"] = lastCopiedAt.Format(time.RFC3339)
|
||||
}
|
||||
if t, ok := s.deps.Tasks.ActiveTaskByDisk(info.DiskID); ok {
|
||||
item["active_task_id"] = t.ID
|
||||
}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func (s *Server) handleDiskStatus(w http.ResponseWriter, r *http.Request) {
|
||||
type response struct {
|
||||
State disk.DiskState `json:"state"`
|
||||
@@ -29,12 +51,12 @@ func (s *Server) handleDiskStatus(w http.ResponseWriter, r *http.Request) {
|
||||
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 payload := s.diskResponse(info); payload != nil {
|
||||
if v, ok := payload["last_copied_at"].(string); ok {
|
||||
item.LastCopiedAt = v
|
||||
}
|
||||
if t, ok := s.deps.Tasks.ActiveTaskByDisk(info.DiskID); ok {
|
||||
item.ActiveTaskID = t.ID
|
||||
if v, ok := payload["active_task_id"].(string); ok {
|
||||
item.ActiveTaskID = v
|
||||
}
|
||||
}
|
||||
resp = append(resp, item)
|
||||
@@ -43,6 +65,16 @@ func (s *Server) handleDiskStatus(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOK(w, map[string]any{"items": resp})
|
||||
}
|
||||
|
||||
func (s *Server) handleDiskProbe(w http.ResponseWriter, r *http.Request) {
|
||||
mountPath := r.URL.Query().Get("mount_path")
|
||||
info, err := s.deps.ProbeDisk(mountPath)
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, s.diskResponse(info))
|
||||
}
|
||||
|
||||
func (s *Server) handleDiskInit(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
MountPath string `json:"mount_path"`
|
||||
@@ -52,9 +84,9 @@ func (s *Server) handleDiskInit(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
info, ok := s.deps.Watcher.DiskByMountPath(req.MountPath)
|
||||
if !ok {
|
||||
jsonErr(w, http.StatusNotFound, "disk not found")
|
||||
info, err := s.deps.ProbeDisk(req.MountPath)
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if info.State == disk.DiskAbsent {
|
||||
@@ -76,7 +108,8 @@ func (s *Server) handleDiskInit(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
s.deps.OnDiskInit(info.MountPath, diskID)
|
||||
s.deps.Watcher.ProbeNow()
|
||||
if s.deps.OnDiskInit != nil {
|
||||
s.deps.OnDiskInit(info.MountPath, diskID)
|
||||
}
|
||||
jsonOK(w, map[string]string{"disk_id": diskID})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -11,20 +10,19 @@ import (
|
||||
)
|
||||
|
||||
func (s *Server) handleSources(w http.ResponseWriter, r *http.Request) {
|
||||
relPath, err := normalizeSourcePath(r.URL.Query().Get("path"))
|
||||
absPath, err := normalizeSourcePathQuery(r.URL.Query().Get("path"))
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
absPath := s.deps.MediaPath
|
||||
if relPath != "" {
|
||||
absPath = filepath.Join(absPath, relPath)
|
||||
if absPath == "" {
|
||||
jsonOK(w, map[string]any{"path": "", "items": []map[string]string{}})
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(absPath)
|
||||
if err != nil {
|
||||
jsonOK(w, map[string]any{"path": relPath, "items": []map[string]string{}})
|
||||
jsonOK(w, map[string]any{"path": absPath, "items": []map[string]string{}})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -38,13 +36,10 @@ func (s *Server) handleSources(w http.ResponseWriter, r *http.Request) {
|
||||
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
childPath := e.Name()
|
||||
if relPath != "" {
|
||||
childPath = filepath.Join(relPath, childPath)
|
||||
}
|
||||
childPath := filepath.Join(absPath, e.Name())
|
||||
items = append(items, item{
|
||||
Name: e.Name(),
|
||||
Path: filepath.ToSlash(childPath),
|
||||
Path: childPath,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -53,26 +48,18 @@ func (s *Server) handleSources(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
|
||||
jsonOK(w, map[string]any{
|
||||
"path": relPath,
|
||||
"path": absPath,
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeSourcePath(raw string) (string, error) {
|
||||
raw, _ = url.QueryUnescape(raw)
|
||||
func normalizeSourcePathQuery(raw string) (string, error) {
|
||||
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, "../") {
|
||||
if !filepath.IsAbs(clean) {
|
||||
return "", errors.New("invalid source path")
|
||||
}
|
||||
return clean, nil
|
||||
|
||||
16
internal/api/handlers_system.go
Normal file
16
internal/api/handlers_system.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"jukebox_maker/internal/dialog"
|
||||
)
|
||||
|
||||
func (s *Server) handlePickFolder(w http.ResponseWriter, r *http.Request) {
|
||||
path, err := dialog.PickFolder()
|
||||
if err != nil {
|
||||
jsonErr(w, http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"path": path})
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"jukebox_maker/internal/config"
|
||||
"jukebox_maker/internal/copier"
|
||||
"jukebox_maker/internal/disk"
|
||||
"jukebox_maker/internal/task"
|
||||
"jukebox_maker/internal/watcher"
|
||||
)
|
||||
@@ -20,8 +21,7 @@ type Deps struct {
|
||||
Watcher *watcher.Watcher
|
||||
Copier *copier.Copier
|
||||
Tasks *task.Store
|
||||
MediaPath string
|
||||
MountPath string
|
||||
ProbeDisk func(mountPath string) (disk.DiskInfo, error)
|
||||
// OnDiskInit вызывается при ручной инициализации диска через UI.
|
||||
OnDiskInit func(mountPath, diskID string)
|
||||
}
|
||||
@@ -60,10 +60,13 @@ func (s *Server) routes() {
|
||||
|
||||
s.mux.HandleFunc("GET /health", s.handleHealth)
|
||||
s.mux.HandleFunc("GET /api/disks", s.handleDiskStatus)
|
||||
s.mux.HandleFunc("GET /api/disks/probe", s.handleDiskProbe)
|
||||
s.mux.HandleFunc("POST /api/disks/init", s.handleDiskInit)
|
||||
s.mux.HandleFunc("POST /api/disks/copy/start", s.handleCopyStartSelected)
|
||||
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/system/pick-folder", s.handlePickFolder)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user