ShuffleDepth in DiskProfile controls how files are selected:
-1 = no shuffle (preserve source order)
0 = all files in random order
N = group files by folder at depth N from /media, shuffle groups,
copy entire group before moving to next
Exposed in disk profile UI as a select with level descriptions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
194 lines
5.0 KiB
Go
194 lines
5.0 KiB
Go
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) copyOptions(cfg *config.Config, diskInfo disk.DiskInfo, overwriteMode config.OverwriteMode) copier.Options {
|
|
opts := copier.Options{
|
|
DiskID: diskInfo.DiskID,
|
|
MountPath: diskInfo.MountPath,
|
|
MediaPath: cfg.MediaPath,
|
|
SourceRules: cfg.Sources,
|
|
AllowedExtensions: cfg.EffectiveAllowedExtensions(),
|
|
OverwriteMode: overwriteMode,
|
|
}
|
|
if p := diskInfo.Profile; p != nil {
|
|
opts.DestFolder = p.DestFolder
|
|
opts.ReserveFreeGB = p.ReserveFreeGB
|
|
opts.FileSelectMode = config.FileSelectMode(p.FileSelectMode)
|
|
opts.Transcode = p.Transcode
|
|
opts.ShuffleDepth = p.ShuffleDepth
|
|
} else {
|
|
opts.DestFolder = cfg.DestFolder
|
|
opts.ReserveFreeGB = cfg.ReserveFreeGB
|
|
opts.FileSelectMode = cfg.FileSelectMode
|
|
}
|
|
return opts
|
|
}
|
|
|
|
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)
|
|
if !ok || diskInfo.State != disk.DiskKnown {
|
|
jsonErr(w, http.StatusUnprocessableEntity, "no initialized disk connected")
|
|
return
|
|
}
|
|
|
|
cfg := s.deps.Config
|
|
if !hasEnabledSources(cfg) {
|
|
jsonErr(w, http.StatusUnprocessableEntity, "no source folders selected")
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
reserveGB := cfg.ReserveFreeGB
|
|
if diskInfo.Profile != nil {
|
|
reserveGB = diskInfo.Profile.ReserveFreeGB
|
|
}
|
|
if diskInfo.FreeBytes <= int64(reserveGB*1e9) {
|
|
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 {
|
|
MountPath string `json:"mount_path"`
|
|
Mode string `json:"mode"`
|
|
}
|
|
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
|
|
switch req.Mode {
|
|
case "", "add":
|
|
overwriteMode = config.OverwriteSkip
|
|
case "replace":
|
|
overwriteMode = config.OverwriteDelete
|
|
default:
|
|
jsonErr(w, http.StatusBadRequest, "invalid copy mode")
|
|
return
|
|
}
|
|
|
|
reserveGB := cfg.ReserveFreeGB
|
|
if diskInfo.Profile != nil {
|
|
reserveGB = diskInfo.Profile.ReserveFreeGB
|
|
}
|
|
if diskInfo.FreeBytes <= int64(reserveGB*1e9) {
|
|
jsonErr(w, http.StatusUnprocessableEntity, "free space is below reserve threshold")
|
|
return
|
|
}
|
|
|
|
if s.deps.OnDiskInit != nil {
|
|
s.deps.OnDiskInit(diskInfo.MountPath, diskInfo.DiskID)
|
|
}
|
|
taskID, err := s.deps.Copier.Start(context.Background(), s.copyOptions(cfg, diskInfo, overwriteMode))
|
|
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) handleCopyCancel(w http.ResponseWriter, r *http.Request) {
|
|
diskID := r.PathValue("diskID")
|
|
s.deps.Copier.Cancel(diskID)
|
|
jsonOK(w, map[string]bool{"ok": true})
|
|
}
|
|
|
|
func (s *Server) handleTaskGet(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
t, ok := s.deps.Tasks.Get(id)
|
|
if !ok {
|
|
jsonErr(w, http.StatusNotFound, "task not found")
|
|
return
|
|
}
|
|
jsonOK(w, t)
|
|
}
|