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)
|
||||
|
||||
@@ -26,15 +26,17 @@ const (
|
||||
type SourceFolder struct {
|
||||
Path string `json:"path"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Root bool `json:"root,omitempty"`
|
||||
}
|
||||
|
||||
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"`
|
||||
MediaPath string `json:"media_path"`
|
||||
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"`
|
||||
}
|
||||
@@ -67,6 +69,8 @@ func Load(path string) (*Config, error) {
|
||||
} else {
|
||||
cfg.DestFolder = defaults().DestFolder
|
||||
}
|
||||
cfg.MediaPath = NormalizeMediaPath(cfg.MediaPath)
|
||||
cfg.Sources = NormalizeSources(cfg.Sources, cfg.MediaPath)
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -86,6 +90,26 @@ func Save(path string, cfg *Config) error {
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
c.MediaPath = NormalizeMediaPath(c.MediaPath)
|
||||
if c.MediaPath != "" {
|
||||
info, err := os.Stat(c.MediaPath)
|
||||
if err != nil {
|
||||
return errors.New("media_path is not accessible")
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return errors.New("media_path must be a directory")
|
||||
}
|
||||
}
|
||||
c.Sources = NormalizeSources(c.Sources, c.MediaPath)
|
||||
for _, source := range c.Sources {
|
||||
info, err := os.Stat(source.Path)
|
||||
if err != nil {
|
||||
return errors.New("source path is not accessible: " + source.Path)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return errors.New("source path must be a directory: " + source.Path)
|
||||
}
|
||||
}
|
||||
if c.ReserveFreeGB < 0 {
|
||||
return errors.New("reserve_free_gb must be >= 0")
|
||||
}
|
||||
@@ -105,6 +129,46 @@ func (c *Config) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func NormalizeMediaPath(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Clean(value)
|
||||
}
|
||||
|
||||
func NormalizeSources(items []SourceFolder, mediaPath string) []SourceFolder {
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
result := make([]SourceFolder, 0, len(items))
|
||||
for _, item := range items {
|
||||
path := normalizeSourcePath(item.Path, mediaPath)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[path]; ok {
|
||||
continue
|
||||
}
|
||||
seen[path] = struct{}{}
|
||||
result = append(result, SourceFolder{
|
||||
Path: path,
|
||||
Enabled: item.Enabled,
|
||||
Root: item.Root,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeSourcePath(value string, mediaPath string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if !filepath.IsAbs(value) && mediaPath != "" {
|
||||
value = filepath.Join(mediaPath, value)
|
||||
}
|
||||
return filepath.Clean(value)
|
||||
}
|
||||
|
||||
func NormalizeDestFolder(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
|
||||
@@ -5,9 +5,10 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -322,7 +323,7 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
|
||||
}
|
||||
|
||||
dstAbs := filepath.Join(destRoot, f.relPath)
|
||||
if err := rsyncFile(ctx, f.srcAbs, dstAbs); err != nil {
|
||||
if err := copyFile(ctx, f.srcAbs, dstAbs); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
c.tasks.Update(taskID, func(t *task.Task) {
|
||||
t.Status = task.StatusCanceled
|
||||
@@ -358,11 +359,24 @@ type fileEntry struct {
|
||||
}
|
||||
|
||||
func buildFileList(mediaPath string, rules []config.SourceFolder, skip map[string]struct{}) ([]fileEntry, error) {
|
||||
roots, ruleMap := normalizeSourceRules(rules)
|
||||
_ = mediaPath
|
||||
roots, selectedRoots, ruleMap := normalizeSourceRules(rules)
|
||||
aliases := sourceAliases(roots)
|
||||
|
||||
var result []fileEntry
|
||||
for _, src := range roots {
|
||||
dir := filepath.Join(mediaPath, src)
|
||||
for _, src := range selectedRoots {
|
||||
root := owningRoot(src, roots)
|
||||
if root == "" {
|
||||
root = src
|
||||
}
|
||||
alias := aliases[root]
|
||||
if alias == "" {
|
||||
alias = filepath.Base(root)
|
||||
if alias == "." || alias == "" || alias == string(filepath.Separator) {
|
||||
alias = "source-" + shortHash(root)
|
||||
}
|
||||
}
|
||||
dir := src
|
||||
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
if err != nil {
|
||||
@@ -371,29 +385,33 @@ func buildFileList(mediaPath string, rules []config.SourceFolder, skip map[strin
|
||||
if path == dir {
|
||||
return nil
|
||||
}
|
||||
rel, relErr := filepath.Rel(mediaPath, path)
|
||||
rel, relErr := filepath.Rel(root, path)
|
||||
if relErr != nil {
|
||||
return nil
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if !isPathEnabled(rel, ruleMap) && !hasEnabledDescendant(rel, ruleMap) {
|
||||
if !isPathEnabled(path, ruleMap) && !hasEnabledDescendant(path, ruleMap) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(mediaPath, path)
|
||||
rel = filepath.ToSlash(rel)
|
||||
if !isPathEnabled(rel, ruleMap) {
|
||||
if !isPathEnabled(path, ruleMap) {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
rel = filepath.ToSlash(rel)
|
||||
destRel := filepath.ToSlash(filepath.Join(alias, rel))
|
||||
if _, skipped := skip[rel]; skipped {
|
||||
return nil
|
||||
}
|
||||
if _, skipped := skip[destRel]; skipped {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
result = append(result, fileEntry{srcAbs: path, relPath: rel, size: info.Size()})
|
||||
result = append(result, fileEntry{srcAbs: path, relPath: destRel, size: info.Size()})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -403,30 +421,35 @@ func buildFileList(mediaPath string, rules []config.SourceFolder, skip map[strin
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeSourceRules(rules []config.SourceFolder) ([]string, map[string]bool) {
|
||||
func normalizeSourceRules(rules []config.SourceFolder) ([]string, []string, map[string]bool) {
|
||||
ruleMap := make(map[string]bool, len(rules))
|
||||
rootSet := make(map[string]struct{})
|
||||
for _, rule := range rules {
|
||||
src := filepath.ToSlash(filepath.Clean(strings.TrimSpace(rule.Path)))
|
||||
src = strings.TrimPrefix(src, "./")
|
||||
src = strings.TrimPrefix(src, "/")
|
||||
src := filepath.Clean(strings.TrimSpace(rule.Path))
|
||||
if src == "" || src == "." {
|
||||
continue
|
||||
}
|
||||
if src == ".." || strings.HasPrefix(src, "../") {
|
||||
continue
|
||||
}
|
||||
ruleMap[src] = rule.Enabled
|
||||
if rule.Root {
|
||||
rootSet[src] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
var roots []string
|
||||
for src := range rootSet {
|
||||
roots = append(roots, src)
|
||||
}
|
||||
sort.Strings(roots)
|
||||
|
||||
var selectedRoots []string
|
||||
for src, enabled := range ruleMap {
|
||||
if !enabled || hasEnabledAncestor(src, ruleMap) {
|
||||
continue
|
||||
}
|
||||
roots = append(roots, src)
|
||||
selectedRoots = append(selectedRoots, src)
|
||||
}
|
||||
sort.Strings(roots)
|
||||
return roots, ruleMap
|
||||
sort.Strings(selectedRoots)
|
||||
return roots, selectedRoots, ruleMap
|
||||
}
|
||||
|
||||
func hasEnabledAncestor(path string, ruleMap map[string]bool) bool {
|
||||
@@ -439,9 +462,8 @@ func hasEnabledAncestor(path string, ruleMap map[string]bool) bool {
|
||||
}
|
||||
|
||||
func hasEnabledDescendant(path string, ruleMap map[string]bool) bool {
|
||||
prefix := path + "/"
|
||||
for other, enabled := range ruleMap {
|
||||
if enabled && strings.HasPrefix(other, prefix) {
|
||||
if enabled && isPathInside(path, other) && other != path {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -458,36 +480,153 @@ func isPathEnabled(path string, ruleMap map[string]bool) bool {
|
||||
}
|
||||
|
||||
func parentSourcePath(path string) string {
|
||||
idx := strings.LastIndex(path, "/")
|
||||
if idx < 0 {
|
||||
parent := filepath.Dir(path)
|
||||
if parent == "." || parent == path {
|
||||
return ""
|
||||
}
|
||||
return path[:idx]
|
||||
return parent
|
||||
}
|
||||
|
||||
// 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 {
|
||||
func owningRoot(path string, roots []string) string {
|
||||
var best string
|
||||
for _, root := range roots {
|
||||
if isPathInside(root, path) {
|
||||
if len(root) > len(best) {
|
||||
best = root
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func sourceAliases(roots []string) map[string]string {
|
||||
counts := make(map[string]int, len(roots))
|
||||
for _, root := range roots {
|
||||
counts[strings.ToLower(filepath.Base(root))]++
|
||||
}
|
||||
|
||||
aliases := make(map[string]string, len(roots))
|
||||
for _, root := range roots {
|
||||
base := filepath.Base(root)
|
||||
if base == "." || base == string(filepath.Separator) || base == "" {
|
||||
base = "source"
|
||||
}
|
||||
key := strings.ToLower(base)
|
||||
if counts[key] > 1 {
|
||||
base = fmt.Sprintf("%s-%s", base, shortHash(root))
|
||||
}
|
||||
aliases[root] = base
|
||||
}
|
||||
return aliases
|
||||
}
|
||||
|
||||
func shortHash(value string) string {
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(value))
|
||||
return fmt.Sprintf("%08x", h.Sum32())[:6]
|
||||
}
|
||||
|
||||
func isPathInside(base, candidate string) bool {
|
||||
if candidate == base {
|
||||
return true
|
||||
}
|
||||
rel, err := filepath.Rel(base, candidate)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
|
||||
}
|
||||
|
||||
func copyFile(ctx context.Context, src, dst string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "rsync",
|
||||
"--partial",
|
||||
"--append-verify",
|
||||
"--times",
|
||||
"--no-perms",
|
||||
"--no-owner",
|
||||
"--no-group",
|
||||
"--chmod=ugo=rwx",
|
||||
src, dst,
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
srcInfo, err := srcFile.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
offset := int64(0)
|
||||
if dstInfo, err := os.Stat(dst); err == nil {
|
||||
switch {
|
||||
case dstInfo.Size() < srcInfo.Size():
|
||||
offset = dstInfo.Size()
|
||||
case dstInfo.Size() == srcInfo.Size():
|
||||
return os.Chtimes(dst, srcInfo.ModTime(), srcInfo.ModTime())
|
||||
default:
|
||||
if err := os.Remove(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("rsync: %w: %s", err, out)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
if offset > 0 {
|
||||
if _, err := srcFile.Seek(offset, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
if offset > 0 {
|
||||
if _, err := dstFile.Seek(offset, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := dstFile.Truncate(0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buf := make([]byte, 1024*1024)
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nr, readErr := srcFile.Read(buf)
|
||||
if nr > 0 {
|
||||
nw, writeErr := dstFile.Write(buf[:nr])
|
||||
if writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
if nw != nr {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
}
|
||||
|
||||
if readErr != nil {
|
||||
if errors.Is(readErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
return readErr
|
||||
}
|
||||
}
|
||||
|
||||
if err := dstFile.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chtimes(dst, srcInfo.ModTime(), srcInfo.ModTime()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstInfo, err := os.Stat(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dstInfo.Size() != srcInfo.Size() {
|
||||
return fmt.Errorf("copied size mismatch for %s", filepath.Base(src))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
20
internal/dialog/pickfolder_darwin.go
Normal file
20
internal/dialog/pickfolder_darwin.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func PickFolder() (string, error) {
|
||||
cmd := exec.Command("osascript", "-e", `POSIX path of (choose folder with prompt "Select a folder")`)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("folder selection failed: %s", strings.TrimSpace(string(out)))
|
||||
}
|
||||
path := strings.TrimSpace(string(out))
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("folder selection canceled")
|
||||
}
|
||||
return strings.TrimSuffix(path, "/"), nil
|
||||
}
|
||||
9
internal/dialog/pickfolder_other.go
Normal file
9
internal/dialog/pickfolder_other.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package dialog
|
||||
|
||||
import "fmt"
|
||||
|
||||
func PickFolder() (string, error) {
|
||||
return "", fmt.Errorf("native folder picker is not supported on this platform")
|
||||
}
|
||||
21
internal/dialog/pickfolder_windows.go
Normal file
21
internal/dialog/pickfolder_windows.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func PickFolder() (string, error) {
|
||||
script := `Add-Type -AssemblyName System.Windows.Forms; $dialog = New-Object System.Windows.Forms.FolderBrowserDialog; $dialog.ShowNewFolderButton = $false; if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Write($dialog.SelectedPath) }`
|
||||
cmd := exec.Command("powershell", "-NoProfile", "-STA", "-Command", script)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("folder selection failed: %s", strings.TrimSpace(string(out)))
|
||||
}
|
||||
path := strings.TrimSpace(string(out))
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("folder selection canceled")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -59,30 +58,6 @@ 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 {
|
||||
@@ -112,13 +87,3 @@ func InitDisk(mountPath string) (string, error) {
|
||||
func DBPath(mountPath string) string {
|
||||
return filepath.Join(mountPath, MarkerDir, "history.db")
|
||||
}
|
||||
|
||||
func DiskUsage(mountPath string) (total, free int64, err error) {
|
||||
var stat syscall.Statfs_t
|
||||
if err = syscall.Statfs(mountPath, &stat); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
total = int64(stat.Blocks) * int64(stat.Bsize)
|
||||
free = int64(stat.Bavail) * int64(stat.Bsize)
|
||||
return total, free, nil
|
||||
}
|
||||
|
||||
43
internal/disk/storage_unix.go
Normal file
43
internal/disk/storage_unix.go
Normal file
@@ -0,0 +1,43 @@
|
||||
//go:build !windows
|
||||
|
||||
package disk
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
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 DiskUsage(mountPath string) (total, free int64, err error) {
|
||||
var stat syscall.Statfs_t
|
||||
if err = syscall.Statfs(mountPath, &stat); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
total = int64(stat.Blocks) * int64(stat.Bsize)
|
||||
free = int64(stat.Bavail) * int64(stat.Bsize)
|
||||
return total, free, nil
|
||||
}
|
||||
40
internal/disk/storage_windows.go
Normal file
40
internal/disk/storage_windows.go
Normal file
@@ -0,0 +1,40 @@
|
||||
//go:build windows
|
||||
|
||||
package disk
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func IsMountPoint(path string) bool {
|
||||
clean := filepath.Clean(path)
|
||||
volume := filepath.VolumeName(clean)
|
||||
if volume == "" {
|
||||
return false
|
||||
}
|
||||
root := volume + `\`
|
||||
return strings.EqualFold(clean, root)
|
||||
}
|
||||
|
||||
func DiskUsage(mountPath string) (total, free int64, err error) {
|
||||
root := filepath.Clean(mountPath)
|
||||
if volume := filepath.VolumeName(root); volume != "" {
|
||||
root = volume + `\`
|
||||
}
|
||||
|
||||
pathPtr, err := windows.UTF16PtrFromString(root)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
var freeBytesAvailable uint64
|
||||
var totalNumberOfBytes uint64
|
||||
var totalNumberOfFreeBytes uint64
|
||||
if err := windows.GetDiskFreeSpaceEx(pathPtr, &freeBytesAvailable, &totalNumberOfBytes, &totalNumberOfFreeBytes); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return int64(totalNumberOfBytes), int64(freeBytesAvailable), nil
|
||||
}
|
||||
Reference in New Issue
Block a user