- handlers_sources.go: revert to relative paths rooted at /media (remove standalone absolute-path mode) - settings.html: remove manual path input, restore auto-loading source tree from /media - config.go: remove filesystem existence checks from Validate() — paths may be temporarily unavailable - transcoder.go: always specify fps in ffmpeg args when MaxFPS is set, preserving source fps if lower than limit - copier.go: include source codec/format and target codec/format in transcoding task message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
337 lines
8.8 KiB
Go
337 lines
8.8 KiB
Go
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
|
|
}
|