Add configurable allowed file types
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+166
-5
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+36
-10
@@ -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{})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user