From 6953c151fe347fd5800a9a05137f3356de929e28 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Fri, 24 Apr 2026 16:36:48 +0300 Subject: [PATCH] Add configurable allowed file types --- cmd/jukebox/main.go | 17 ++-- internal/api/handlers_copy.go | 17 ++-- internal/config/config.go | 171 ++++++++++++++++++++++++++++++++- internal/config/config_test.go | 68 +++++++++++++ internal/copier/copier.go | 46 +++++++-- internal/copier/copier_test.go | 54 +++++++++++ web/templates/settings.html | 115 ++++++++++++++++++++-- 7 files changed, 451 insertions(+), 37 deletions(-) create mode 100644 internal/config/config_test.go create mode 100644 internal/copier/copier_test.go diff --git a/cmd/jukebox/main.go b/cmd/jukebox/main.go index ec3da79..701ba52 100644 --- a/cmd/jukebox/main.go +++ b/cmd/jukebox/main.go @@ -209,14 +209,15 @@ func triggerAutoCopy(cp *copier.Copier, cfg *config.Config, info disk.DiskInfo) } go func() { _, err := cp.Start(context.Background(), copier.Options{ - DiskID: info.DiskID, - MountPath: info.MountPath, - MediaPath: cfg.MediaPath, - DestFolder: cfg.DestFolder, - SourceRules: cfg.Sources, - ReserveFreeGB: cfg.ReserveFreeGB, - OverwriteMode: cfg.OverwriteMode, - FileSelectMode: cfg.FileSelectMode, + DiskID: info.DiskID, + MountPath: info.MountPath, + MediaPath: cfg.MediaPath, + DestFolder: cfg.DestFolder, + SourceRules: cfg.Sources, + AllowedExtensions: cfg.EffectiveAllowedExtensions(), + ReserveFreeGB: cfg.ReserveFreeGB, + OverwriteMode: cfg.OverwriteMode, + FileSelectMode: cfg.FileSelectMode, }) if err != nil { log.Printf("auto-copy: %v", err) diff --git a/internal/api/handlers_copy.go b/internal/api/handlers_copy.go index 77b4a25..3e72791 100644 --- a/internal/api/handlers_copy.go +++ b/internal/api/handlers_copy.go @@ -14,14 +14,15 @@ import ( 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, + DiskID: diskInfo.DiskID, + MountPath: diskInfo.MountPath, + MediaPath: cfg.MediaPath, + DestFolder: cfg.DestFolder, + SourceRules: cfg.Sources, + AllowedExtensions: cfg.EffectiveAllowedExtensions(), + ReserveFreeGB: cfg.ReserveFreeGB, + OverwriteMode: overwriteMode, + FileSelectMode: cfg.FileSelectMode, } } diff --git a/internal/config/config.go b/internal/config/config.go index a10dc06..94bb841 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,7 @@ import ( type OverwriteMode string type FileSelectMode string +type AllowedFilesMode string const ( DefaultDestFolder = "media" @@ -21,6 +22,13 @@ const ( SelectNew FileSelectMode = "new" SelectAll FileSelectMode = "all" + + AllowedFilesByMediaType AllowedFilesMode = "media_types" + AllowedFilesByExtensions AllowedFilesMode = "extensions" + + MediaTypeAudio = "audio" + MediaTypeVideo = "video" + MediaTypePhoto = "photo" ) type SourceFolder struct { @@ -36,6 +44,9 @@ type Config struct { 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"` @@ -43,11 +54,14 @@ type Config struct { func defaults() Config { return Config{ - ReserveFreeGB: 2.0, - DestFolder: DefaultDestFolder, - OverwriteMode: OverwriteSkip, - FileSelectMode: SelectNew, - AutoCopy: false, + ReserveFreeGB: 2.0, + DestFolder: DefaultDestFolder, + OverwriteMode: OverwriteSkip, + FileSelectMode: SelectNew, + AllowedFilesMode: AllowedFilesByMediaType, + EnabledMediaTypes: DefaultEnabledMediaTypes(), + AllowedExtensions: DefaultAllowedExtensions(), + AutoCopy: false, } } @@ -69,6 +83,17 @@ func Load(path string) (*Config, error) { } 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 @@ -126,9 +151,145 @@ func (c *Config) Validate() error { 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 == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..0cb1634 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,68 @@ +package config + +import "testing" + +func TestDefaultsAllowedFiles(t *testing.T) { + cfg := defaults() + + if cfg.AllowedFilesMode != AllowedFilesByMediaType { + t.Fatalf("allowed files mode = %q, want %q", cfg.AllowedFilesMode, AllowedFilesByMediaType) + } + + wantTypes := []string{MediaTypeAudio, MediaTypeVideo} + if len(cfg.EnabledMediaTypes) != len(wantTypes) { + t.Fatalf("enabled media types len = %d, want %d", len(cfg.EnabledMediaTypes), len(wantTypes)) + } + for i, want := range wantTypes { + if cfg.EnabledMediaTypes[i] != want { + t.Fatalf("enabled media types[%d] = %q, want %q", i, cfg.EnabledMediaTypes[i], want) + } + } + + exts := cfg.EffectiveAllowedExtensions() + if containsString(exts, ".jpg") { + t.Fatalf("default extensions unexpectedly include photo files: %v", exts) + } + if !containsString(exts, ".mp3") || !containsString(exts, ".mp4") { + t.Fatalf("default extensions = %v, want audio/video entries", exts) + } +} + +func TestValidateAllowedFilesModeExtensions(t *testing.T) { + cfg := defaults() + cfg.AllowedFilesMode = AllowedFilesByExtensions + cfg.AllowedExtensions = []string{" mp3 ", ".MP4", "*.jpg", ".mp3"} + + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } + + want := []string{".mp3", ".mp4", ".jpg"} + if len(cfg.AllowedExtensions) != len(want) { + t.Fatalf("allowed extensions len = %d, want %d", len(cfg.AllowedExtensions), len(want)) + } + for i, item := range want { + if cfg.AllowedExtensions[i] != item { + t.Fatalf("allowed extensions[%d] = %q, want %q", i, cfg.AllowedExtensions[i], item) + } + } +} + +func TestValidateRejectsEmptyAllowedFiles(t *testing.T) { + cfg := defaults() + cfg.AllowedFilesMode = AllowedFilesByExtensions + cfg.AllowedExtensions = nil + + if err := cfg.Validate(); err == nil { + t.Fatal("Validate() error = nil, want non-nil") + } +} + +func containsString(items []string, want string) bool { + for _, item := range items { + if item == want { + return true + } + } + return false +} diff --git a/internal/copier/copier.go b/internal/copier/copier.go index 8eef009..ee2c180 100644 --- a/internal/copier/copier.go +++ b/internal/copier/copier.go @@ -22,14 +22,15 @@ import ( ) type Options struct { - DiskID string - MountPath string - MediaPath string - DestFolder string // subfolder on disk, default "media" - SourceRules []config.SourceFolder - ReserveFreeGB float64 - OverwriteMode config.OverwriteMode - FileSelectMode config.FileSelectMode + DiskID string + MountPath string + MediaPath string + DestFolder string // subfolder on disk, default "media" + SourceRules []config.SourceFolder + AllowedExtensions []string + ReserveFreeGB float64 + OverwriteMode config.OverwriteMode + FileSelectMode config.FileSelectMode } type Copier struct { @@ -241,7 +242,7 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database if t, ok := c.tasks.Get(taskID); ok { _ = database.UpdateTask(*t) } - files, err := buildFileList(opts.MediaPath, opts.SourceRules, copiedPaths) + files, err := buildFileList(opts.MediaPath, opts.SourceRules, copiedPaths, opts.AllowedExtensions) if err != nil { fail(err) return @@ -358,10 +359,11 @@ type fileEntry struct { size int64 } -func buildFileList(mediaPath string, rules []config.SourceFolder, skip map[string]struct{}) ([]fileEntry, error) { +func buildFileList(mediaPath string, rules []config.SourceFolder, skip map[string]struct{}, allowedExtensions []string) ([]fileEntry, error) { _ = mediaPath roots, selectedRoots, ruleMap := normalizeSourceRules(rules) aliases := sourceAliases(roots) + allowedExts := makeAllowedExtensionSet(allowedExtensions) var result []fileEntry for _, src := range selectedRoots { @@ -398,6 +400,9 @@ func buildFileList(mediaPath string, rules []config.SourceFolder, skip map[strin if !isPathEnabled(path, ruleMap) { return nil } + if !isExtensionAllowed(path, allowedExts) { + return nil + } rel, _ := filepath.Rel(root, path) rel = filepath.ToSlash(rel) destRel := filepath.ToSlash(filepath.Join(alias, rel)) @@ -421,6 +426,27 @@ func buildFileList(mediaPath string, rules []config.SourceFolder, skip map[strin return result, nil } +func makeAllowedExtensionSet(items []string) map[string]struct{} { + normalized := config.NormalizeExtensions(items) + if len(normalized) == 0 { + normalized = config.DefaultAllowedExtensions() + } + result := make(map[string]struct{}, len(normalized)) + for _, item := range normalized { + result[item] = struct{}{} + } + return result +} + +func isExtensionAllowed(path string, allowed map[string]struct{}) bool { + ext := strings.ToLower(filepath.Ext(path)) + if ext == "" { + return false + } + _, ok := allowed[ext] + return ok +} + func normalizeSourceRules(rules []config.SourceFolder) ([]string, []string, map[string]bool) { ruleMap := make(map[string]bool, len(rules)) rootSet := make(map[string]struct{}) diff --git a/internal/copier/copier_test.go b/internal/copier/copier_test.go new file mode 100644 index 0000000..0ceb1d2 --- /dev/null +++ b/internal/copier/copier_test.go @@ -0,0 +1,54 @@ +package copier + +import ( + "os" + "path/filepath" + "testing" + + "jukebox_maker/internal/config" +) + +func TestBuildFileListFiltersAllowedExtensions(t *testing.T) { + root := t.TempDir() + source := filepath.Join(root, "music") + if err := os.MkdirAll(filepath.Join(source, "nested"), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + + files := map[string]string{ + filepath.Join(source, "track.mp3"): "audio", + filepath.Join(source, "clip.mp4"): "video", + filepath.Join(source, "cover.jpg"): "photo", + filepath.Join(source, "nested", "note.txt"): "text", + } + for path, body := range files { + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", path, err) + } + } + + items, err := buildFileList("", []config.SourceFolder{ + {Path: source, Enabled: true, Root: true}, + }, nil, config.DefaultAllowedExtensions()) + if err != nil { + t.Fatalf("buildFileList() error = %v", err) + } + + if len(items) != 2 { + t.Fatalf("buildFileList() len = %d, want 2", len(items)) + } + + got := make(map[string]struct{}, len(items)) + for _, item := range items { + got[filepath.Base(item.relPath)] = struct{}{} + } + if _, ok := got["track.mp3"]; !ok { + t.Fatalf("missing mp3 file: %v", got) + } + if _, ok := got["clip.mp4"]; !ok { + t.Fatalf("missing mp4 file: %v", got) + } + if _, ok := got["cover.jpg"]; ok { + t.Fatalf("unexpected jpg file: %v", got) + } +} diff --git a/web/templates/settings.html b/web/templates/settings.html index 0eef902..b48706d 100644 --- a/web/templates/settings.html +++ b/web/templates/settings.html @@ -36,6 +36,48 @@ The new-only mode skips files already copied to this disk, even if they were later removed. +
+ + +
+ +
+ +
+ + + +
+ Built into the app by default: audio, video, and photo. New installations start with only audio and video enabled. +
+ + +
@@ -78,6 +120,11 @@ const sourceTree = new Map(); const expandedNodes = new Set(); const loadingNodes = new Set(); +const builtInMediaTypes = { + audio: ['.aac', '.aif', '.aiff', '.alac', '.ape', '.flac', '.m4a', '.mp2', '.mp3', '.ogg', '.opus', '.wav', '.wma'], + video: ['.3gp', '.avi', '.m2ts', '.m4v', '.mkv', '.mov', '.mp4', '.mpeg', '.mpg', '.mts', '.ts', '.webm', '.wmv'], + photo: ['.bmp', '.gif', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.tif', '.tiff', '.webp'], +}; let sourceRoots = []; let sourceConfig = {}; @@ -328,6 +375,52 @@ function deriveRootsFromSources(sources) { .filter((path, index, all) => !all.some((other, otherIndex) => otherIndex !== index && isPathWithin(other, path) && !isSamePath(other, path))); } +function defaultAllowedExtensions() { + return [...builtInMediaTypes.audio, ...builtInMediaTypes.video]; +} + +function parseExtensionsInput(value) { + const items = String(value || '') + .split(/[\n,]+/) + .map((item) => item.trim()) + .filter(Boolean); + + const result = []; + const seen = new Set(); + items.forEach((item) => { + let value = item.toLowerCase().replace(/^\*/, ''); + if (!value.startsWith('.')) value = '.' + value; + if (!/^\.[a-z0-9]+$/.test(value)) return; + if (seen.has(value)) return; + seen.add(value); + result.push(value); + }); + return result; +} + +function formatExtensionsInput(items) { + return (items || []).join('\n'); +} + +function selectedMediaTypes() { + return ['audio', 'video', 'photo'].filter((name) => { + const id = 'mediaType' + name.charAt(0).toUpperCase() + name.slice(1); + return document.getElementById(id).checked; + }); +} + +function updateAllowedFilesModeUI() { + const mode = document.getElementById('allowedFilesMode').value || 'media_types'; + document.getElementById('mediaTypesGroup').style.display = mode === 'media_types' ? '' : 'none'; + document.getElementById('extensionsGroup').style.display = mode === 'extensions' ? '' : 'none'; +} + +function renderMediaTypeHints() { + document.getElementById('mediaTypeAudioHint').textContent = builtInMediaTypes.audio.join(', '); + document.getElementById('mediaTypeVideoHint').textContent = builtInMediaTypes.video.join(', '); + document.getElementById('mediaTypePhotoHint').textContent = builtInMediaTypes.photo.join(', '); +} + async function loadSettings() { try { const r = await fetch('/api/config'); @@ -336,8 +429,14 @@ async function loadSettings() { document.getElementById('reserveGB').value = cfg.reserve_free_gb ?? 2; document.getElementById('destFolder').value = cfg.dest_folder || 'media'; document.getElementById('fileSelectMode').value = cfg.file_select_mode || 'new'; + document.getElementById('allowedFilesMode').value = cfg.allowed_files_mode || 'media_types'; document.getElementById('overwriteMode').value = cfg.overwrite_mode || 'skip'; document.getElementById('autoCopy').checked = !!cfg.auto_copy; + document.getElementById('mediaTypeAudio').checked = (cfg.enabled_media_types || ['audio', 'video']).includes('audio'); + document.getElementById('mediaTypeVideo').checked = (cfg.enabled_media_types || ['audio', 'video']).includes('video'); + document.getElementById('mediaTypePhoto').checked = (cfg.enabled_media_types || []).includes('photo'); + document.getElementById('allowedExtensions').value = formatExtensionsInput((cfg.allowed_extensions || []).length ? cfg.allowed_extensions : defaultAllowedExtensions()); + updateAllowedFilesModeUI(); sourceConfig = {}; (cfg.sources || []).forEach((source) => { @@ -354,12 +453,15 @@ async function saveSettings(event) { event.preventDefault(); const body = { - reserve_free_gb: parseFloat(document.getElementById('reserveGB').value) || 2, - dest_folder: document.getElementById('destFolder').value.trim() || 'media', - file_select_mode: document.getElementById('fileSelectMode').value, - overwrite_mode: document.getElementById('overwriteMode').value, - auto_copy: document.getElementById('autoCopy').checked, - sources: collectSourcesForSave(), + reserve_free_gb: parseFloat(document.getElementById('reserveGB').value) || 2, + dest_folder: document.getElementById('destFolder').value.trim() || 'media', + file_select_mode: document.getElementById('fileSelectMode').value, + allowed_files_mode: document.getElementById('allowedFilesMode').value, + enabled_media_types: selectedMediaTypes(), + allowed_extensions: parseExtensionsInput(document.getElementById('allowedExtensions').value), + overwrite_mode: document.getElementById('overwriteMode').value, + auto_copy: document.getElementById('autoCopy').checked, + sources: collectSourcesForSave(), }; try { @@ -405,6 +507,7 @@ document.getElementById('sourceTree').addEventListener('change', (event) => { toggleSource(checkbox.dataset.path, checkbox.checked); }); +renderMediaTypeHints(); loadSettings(); {{end}}