package config import ( "encoding/json" "errors" "os" "path/filepath" "strings" "jukebox_maker/internal/disk" ) type OverwriteMode string type FileSelectMode string type AllowedFilesMode string const ( DefaultDestFolder = "media" OverwriteSkip OverwriteMode = "skip" OverwriteDelete OverwriteMode = "delete" SelectNew FileSelectMode = "new" SelectAll FileSelectMode = "all" AllowedFilesByMediaType AllowedFilesMode = "media_types" AllowedFilesByExtensions AllowedFilesMode = "extensions" MediaTypeAudio = "audio" MediaTypeVideo = "video" MediaTypePhoto = "photo" ) 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"` AllowedFilesMode AllowedFilesMode `json:"allowed_files_mode"` EnabledMediaTypes []string `json:"enabled_media_types,omitempty"` AllowedExtensions []string `json:"allowed_extensions,omitempty"` 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, AllowedFilesMode: AllowedFilesByMediaType, EnabledMediaTypes: DefaultEnabledMediaTypes(), AllowedExtensions: DefaultAllowedExtensions(), 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 } if cfg.AllowedFilesMode != AllowedFilesByMediaType && cfg.AllowedFilesMode != AllowedFilesByExtensions { cfg.AllowedFilesMode = defaults().AllowedFilesMode } cfg.EnabledMediaTypes = NormalizeMediaTypes(cfg.EnabledMediaTypes) if len(cfg.EnabledMediaTypes) == 0 { cfg.EnabledMediaTypes = defaults().EnabledMediaTypes } cfg.AllowedExtensions = NormalizeExtensions(cfg.AllowedExtensions) if len(cfg.AllowedExtensions) == 0 { cfg.AllowedExtensions = defaults().AllowedExtensions } 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) c.Sources = NormalizeSources(c.Sources, c.MediaPath) 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'") } switch c.AllowedFilesMode { case "", AllowedFilesByMediaType: c.AllowedFilesMode = AllowedFilesByMediaType c.EnabledMediaTypes = NormalizeMediaTypes(c.EnabledMediaTypes) if len(c.EnabledMediaTypes) == 0 { return errors.New("enabled_media_types must contain at least one of: audio, video, photo") } case AllowedFilesByExtensions: c.AllowedExtensions = NormalizeExtensions(c.AllowedExtensions) if len(c.AllowedExtensions) == 0 { return errors.New("allowed_extensions must contain at least one file extension") } default: return errors.New("allowed_files_mode must be 'media_types' or 'extensions'") } return nil } func DefaultEnabledMediaTypes() []string { return []string{MediaTypeAudio, MediaTypeVideo} } func DefaultAllowedExtensions() []string { return extensionsForMediaTypes(DefaultEnabledMediaTypes()) } func BuiltInMediaTypeExtensions() map[string][]string { return map[string][]string{ MediaTypeAudio: { ".aac", ".aif", ".aiff", ".alac", ".ape", ".flac", ".m4a", ".mp2", ".mp3", ".ogg", ".opus", ".wav", ".wma", }, MediaTypeVideo: { ".3gp", ".avi", ".m2ts", ".m4v", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".mts", ".ts", ".webm", ".wmv", }, MediaTypePhoto: { ".bmp", ".gif", ".heic", ".heif", ".jpeg", ".jpg", ".png", ".tif", ".tiff", ".webp", }, } } func NormalizeMediaTypes(items []string) []string { order := []string{MediaTypeAudio, MediaTypeVideo, MediaTypePhoto} allowed := make(map[string]struct{}, len(order)) for _, item := range order { allowed[item] = struct{}{} } seen := make(map[string]struct{}, len(items)) result := make([]string, 0, len(order)) for _, item := range items { value := strings.ToLower(strings.TrimSpace(item)) if _, ok := allowed[value]; !ok { continue } if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} } for _, item := range order { if _, ok := seen[item]; ok { result = append(result, item) } } return result } func NormalizeExtensions(items []string) []string { seen := make(map[string]struct{}, len(items)) result := make([]string, 0, len(items)) for _, item := range items { value := normalizeExtension(item) if value == "" { continue } if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} result = append(result, value) } return result } func normalizeExtension(value string) string { value = strings.ToLower(strings.TrimSpace(value)) value = strings.TrimPrefix(value, "*") if value == "" { return "" } if !strings.HasPrefix(value, ".") { value = "." + value } if len(value) < 2 { return "" } for _, ch := range value[1:] { switch { case ch >= 'a' && ch <= 'z': case ch >= '0' && ch <= '9': default: return "" } } return value } func (c Config) EffectiveAllowedExtensions() []string { switch c.AllowedFilesMode { case AllowedFilesByExtensions: if items := NormalizeExtensions(c.AllowedExtensions); len(items) > 0 { return items } default: types := NormalizeMediaTypes(c.EnabledMediaTypes) if len(types) == 0 { types = DefaultEnabledMediaTypes() } return extensionsForMediaTypes(types) } return DefaultAllowedExtensions() } func extensionsForMediaTypes(items []string) []string { sets := BuiltInMediaTypeExtensions() result := make([]string, 0) seen := make(map[string]struct{}) for _, mediaType := range NormalizeMediaTypes(items) { for _, ext := range sets[mediaType] { if _, ok := seen[ext]; ok { continue } seen[ext] = struct{}{} result = append(result, ext) } } return result } 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 }