package config import ( "encoding/json" "errors" "os" "path/filepath" "strings" "jukebox_maker/internal/disk" ) type OverwriteMode string type FileSelectMode string const ( DefaultDestFolder = "media" OverwriteSkip OverwriteMode = "skip" OverwriteDelete OverwriteMode = "delete" SelectNew FileSelectMode = "new" SelectAll FileSelectMode = "all" ) type SourceFolder struct { Path string `json:"path"` Enabled bool `json:"enabled"` Root bool `json:"root,omitempty"` } type Config struct { 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"` } func defaults() Config { return Config{ ReserveFreeGB: 2.0, DestFolder: DefaultDestFolder, OverwriteMode: OverwriteSkip, FileSelectMode: SelectNew, AutoCopy: false, } } func Load(path string) (*Config, error) { data, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { cfg := defaults() return &cfg, nil } if err != nil { return nil, err } cfg := defaults() if err := json.Unmarshal(data, &cfg); err != nil { return nil, err } if destFolder, err := NormalizeDestFolder(cfg.DestFolder); err == nil { cfg.DestFolder = destFolder } else { cfg.DestFolder = defaults().DestFolder } cfg.MediaPath = NormalizeMediaPath(cfg.MediaPath) cfg.Sources = NormalizeSources(cfg.Sources, cfg.MediaPath) return &cfg, nil } func Save(path string, cfg *Config) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } tmp := path + ".tmp" if err := os.WriteFile(tmp, data, 0o644); err != nil { return err } return os.Rename(tmp, path) } 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") } if _, err := NormalizeDestFolder(c.DestFolder); err != nil { return err } switch c.OverwriteMode { case OverwriteSkip, OverwriteDelete: default: return errors.New("overwrite_mode must be 'skip' or 'delete'") } switch c.FileSelectMode { case SelectNew, SelectAll: default: return errors.New("file_select_mode must be 'new' or 'all'") } 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 == "" { return DefaultDestFolder, nil } clean := filepath.ToSlash(filepath.Clean(value)) clean = strings.TrimPrefix(clean, "./") clean = strings.TrimPrefix(clean, "/") switch clean { case "", ".", "..": return "", errors.New("dest_folder must be a subfolder on disk, not the disk root") } if strings.HasPrefix(clean, "../") { return "", errors.New("dest_folder must stay inside the disk") } if clean == disk.MarkerDir || strings.HasPrefix(clean, disk.MarkerDir+"/") { return "", errors.New("dest_folder conflicts with internal disk metadata") } return clean, nil }