Add configurable allowed file types
This commit is contained in:
@@ -209,14 +209,15 @@ func triggerAutoCopy(cp *copier.Copier, cfg *config.Config, info disk.DiskInfo)
|
|||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
_, err := cp.Start(context.Background(), copier.Options{
|
_, err := cp.Start(context.Background(), copier.Options{
|
||||||
DiskID: info.DiskID,
|
DiskID: info.DiskID,
|
||||||
MountPath: info.MountPath,
|
MountPath: info.MountPath,
|
||||||
MediaPath: cfg.MediaPath,
|
MediaPath: cfg.MediaPath,
|
||||||
DestFolder: cfg.DestFolder,
|
DestFolder: cfg.DestFolder,
|
||||||
SourceRules: cfg.Sources,
|
SourceRules: cfg.Sources,
|
||||||
ReserveFreeGB: cfg.ReserveFreeGB,
|
AllowedExtensions: cfg.EffectiveAllowedExtensions(),
|
||||||
OverwriteMode: cfg.OverwriteMode,
|
ReserveFreeGB: cfg.ReserveFreeGB,
|
||||||
FileSelectMode: cfg.FileSelectMode,
|
OverwriteMode: cfg.OverwriteMode,
|
||||||
|
FileSelectMode: cfg.FileSelectMode,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("auto-copy: %v", err)
|
log.Printf("auto-copy: %v", err)
|
||||||
|
|||||||
@@ -14,14 +14,15 @@ import (
|
|||||||
|
|
||||||
func (s *Server) copyOptions(cfg *config.Config, diskInfo disk.DiskInfo, overwriteMode config.OverwriteMode) copier.Options {
|
func (s *Server) copyOptions(cfg *config.Config, diskInfo disk.DiskInfo, overwriteMode config.OverwriteMode) copier.Options {
|
||||||
return copier.Options{
|
return copier.Options{
|
||||||
DiskID: diskInfo.DiskID,
|
DiskID: diskInfo.DiskID,
|
||||||
MountPath: diskInfo.MountPath,
|
MountPath: diskInfo.MountPath,
|
||||||
MediaPath: cfg.MediaPath,
|
MediaPath: cfg.MediaPath,
|
||||||
DestFolder: cfg.DestFolder,
|
DestFolder: cfg.DestFolder,
|
||||||
SourceRules: cfg.Sources,
|
SourceRules: cfg.Sources,
|
||||||
ReserveFreeGB: cfg.ReserveFreeGB,
|
AllowedExtensions: cfg.EffectiveAllowedExtensions(),
|
||||||
OverwriteMode: overwriteMode,
|
ReserveFreeGB: cfg.ReserveFreeGB,
|
||||||
FileSelectMode: cfg.FileSelectMode,
|
OverwriteMode: overwriteMode,
|
||||||
|
FileSelectMode: cfg.FileSelectMode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
type OverwriteMode string
|
type OverwriteMode string
|
||||||
type FileSelectMode string
|
type FileSelectMode string
|
||||||
|
type AllowedFilesMode string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultDestFolder = "media"
|
DefaultDestFolder = "media"
|
||||||
@@ -21,6 +22,13 @@ const (
|
|||||||
|
|
||||||
SelectNew FileSelectMode = "new"
|
SelectNew FileSelectMode = "new"
|
||||||
SelectAll FileSelectMode = "all"
|
SelectAll FileSelectMode = "all"
|
||||||
|
|
||||||
|
AllowedFilesByMediaType AllowedFilesMode = "media_types"
|
||||||
|
AllowedFilesByExtensions AllowedFilesMode = "extensions"
|
||||||
|
|
||||||
|
MediaTypeAudio = "audio"
|
||||||
|
MediaTypeVideo = "video"
|
||||||
|
MediaTypePhoto = "photo"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SourceFolder struct {
|
type SourceFolder struct {
|
||||||
@@ -36,6 +44,9 @@ type Config struct {
|
|||||||
Sources []SourceFolder `json:"sources"`
|
Sources []SourceFolder `json:"sources"`
|
||||||
OverwriteMode OverwriteMode `json:"overwrite_mode"`
|
OverwriteMode OverwriteMode `json:"overwrite_mode"`
|
||||||
FileSelectMode FileSelectMode `json:"file_select_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"`
|
AutoCopy bool `json:"auto_copy"`
|
||||||
FileReplicaCounts map[string]int `json:"file_replica_counts,omitempty"`
|
FileReplicaCounts map[string]int `json:"file_replica_counts,omitempty"`
|
||||||
DiskReplicaFiles map[string][]string `json:"disk_replica_files,omitempty"`
|
DiskReplicaFiles map[string][]string `json:"disk_replica_files,omitempty"`
|
||||||
@@ -43,11 +54,14 @@ type Config struct {
|
|||||||
|
|
||||||
func defaults() Config {
|
func defaults() Config {
|
||||||
return Config{
|
return Config{
|
||||||
ReserveFreeGB: 2.0,
|
ReserveFreeGB: 2.0,
|
||||||
DestFolder: DefaultDestFolder,
|
DestFolder: DefaultDestFolder,
|
||||||
OverwriteMode: OverwriteSkip,
|
OverwriteMode: OverwriteSkip,
|
||||||
FileSelectMode: SelectNew,
|
FileSelectMode: SelectNew,
|
||||||
AutoCopy: false,
|
AllowedFilesMode: AllowedFilesByMediaType,
|
||||||
|
EnabledMediaTypes: DefaultEnabledMediaTypes(),
|
||||||
|
AllowedExtensions: DefaultAllowedExtensions(),
|
||||||
|
AutoCopy: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +83,17 @@ func Load(path string) (*Config, error) {
|
|||||||
} else {
|
} else {
|
||||||
cfg.DestFolder = defaults().DestFolder
|
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.MediaPath = NormalizeMediaPath(cfg.MediaPath)
|
||||||
cfg.Sources = NormalizeSources(cfg.Sources, cfg.MediaPath)
|
cfg.Sources = NormalizeSources(cfg.Sources, cfg.MediaPath)
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
@@ -126,9 +151,145 @@ func (c *Config) Validate() error {
|
|||||||
default:
|
default:
|
||||||
return errors.New("file_select_mode must be 'new' or 'all'")
|
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
|
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 {
|
func NormalizeMediaPath(value string) string {
|
||||||
value = strings.TrimSpace(value)
|
value = strings.TrimSpace(value)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
|
|||||||
68
internal/config/config_test.go
Normal file
68
internal/config/config_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -22,14 +22,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
DiskID string
|
DiskID string
|
||||||
MountPath string
|
MountPath string
|
||||||
MediaPath string
|
MediaPath string
|
||||||
DestFolder string // subfolder on disk, default "media"
|
DestFolder string // subfolder on disk, default "media"
|
||||||
SourceRules []config.SourceFolder
|
SourceRules []config.SourceFolder
|
||||||
ReserveFreeGB float64
|
AllowedExtensions []string
|
||||||
OverwriteMode config.OverwriteMode
|
ReserveFreeGB float64
|
||||||
FileSelectMode config.FileSelectMode
|
OverwriteMode config.OverwriteMode
|
||||||
|
FileSelectMode config.FileSelectMode
|
||||||
}
|
}
|
||||||
|
|
||||||
type Copier struct {
|
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 {
|
if t, ok := c.tasks.Get(taskID); ok {
|
||||||
_ = database.UpdateTask(*t)
|
_ = database.UpdateTask(*t)
|
||||||
}
|
}
|
||||||
files, err := buildFileList(opts.MediaPath, opts.SourceRules, copiedPaths)
|
files, err := buildFileList(opts.MediaPath, opts.SourceRules, copiedPaths, opts.AllowedExtensions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(err)
|
fail(err)
|
||||||
return
|
return
|
||||||
@@ -358,10 +359,11 @@ type fileEntry struct {
|
|||||||
size int64
|
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
|
_ = mediaPath
|
||||||
roots, selectedRoots, ruleMap := normalizeSourceRules(rules)
|
roots, selectedRoots, ruleMap := normalizeSourceRules(rules)
|
||||||
aliases := sourceAliases(roots)
|
aliases := sourceAliases(roots)
|
||||||
|
allowedExts := makeAllowedExtensionSet(allowedExtensions)
|
||||||
|
|
||||||
var result []fileEntry
|
var result []fileEntry
|
||||||
for _, src := range selectedRoots {
|
for _, src := range selectedRoots {
|
||||||
@@ -398,6 +400,9 @@ func buildFileList(mediaPath string, rules []config.SourceFolder, skip map[strin
|
|||||||
if !isPathEnabled(path, ruleMap) {
|
if !isPathEnabled(path, ruleMap) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if !isExtensionAllowed(path, allowedExts) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
rel, _ := filepath.Rel(root, path)
|
rel, _ := filepath.Rel(root, path)
|
||||||
rel = filepath.ToSlash(rel)
|
rel = filepath.ToSlash(rel)
|
||||||
destRel := filepath.ToSlash(filepath.Join(alias, 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
|
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) {
|
func normalizeSourceRules(rules []config.SourceFolder) ([]string, []string, map[string]bool) {
|
||||||
ruleMap := make(map[string]bool, len(rules))
|
ruleMap := make(map[string]bool, len(rules))
|
||||||
rootSet := make(map[string]struct{})
|
rootSet := make(map[string]struct{})
|
||||||
|
|||||||
54
internal/copier/copier_test.go
Normal file
54
internal/copier/copier_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,48 @@
|
|||||||
<span class="form-hint">The new-only mode skips files already copied to this disk, even if they were later removed.</span>
|
<span class="form-hint">The new-only mode skips files already copied to this disk, even if they were later removed.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="allowedFilesMode">Allowed file types</label>
|
||||||
|
<select class="form-select" id="allowedFilesMode" style="width:auto;max-width:420px" onchange="updateAllowedFilesModeUI()">
|
||||||
|
<option value="media_types">Audio, video, photo</option>
|
||||||
|
<option value="extensions">Custom extensions list</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="mediaTypesGroup">
|
||||||
|
<label class="form-label">Built-in media types</label>
|
||||||
|
<div style="display:grid;gap:8px">
|
||||||
|
<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="mediaTypeAudio" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||||
|
<span>
|
||||||
|
<strong>Audio</strong>
|
||||||
|
<span class="form-hint" id="mediaTypeAudioHint" style="display:block"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="mediaTypeVideo" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||||
|
<span>
|
||||||
|
<strong>Video</strong>
|
||||||
|
<span class="form-hint" id="mediaTypeVideoHint" style="display:block"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="mediaTypePhoto" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||||
|
<span>
|
||||||
|
<strong>Photo</strong>
|
||||||
|
<span class="form-hint" id="mediaTypePhotoHint" style="display:block"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<span class="form-hint">Built into the app by default: audio, video, and photo. New installations start with only audio and video enabled.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="extensionsGroup" style="display:none">
|
||||||
|
<label class="form-label" for="allowedExtensions">Allowed extensions</label>
|
||||||
|
<textarea class="form-input" id="allowedExtensions" rows="5" placeholder=".mp3, .flac, .mp4"></textarea>
|
||||||
|
<span class="form-hint">One extension per line or separated by commas. You can write <code>mp3</code> or <code>.mp3</code>.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="destFolder">Destination folder on disk</label>
|
<label class="form-label" for="destFolder">Destination folder on disk</label>
|
||||||
<input class="form-input" type="text" id="destFolder" placeholder="media" style="width:200px">
|
<input class="form-input" type="text" id="destFolder" placeholder="media" style="width:200px">
|
||||||
@@ -78,6 +120,11 @@
|
|||||||
const sourceTree = new Map();
|
const sourceTree = new Map();
|
||||||
const expandedNodes = new Set();
|
const expandedNodes = new Set();
|
||||||
const loadingNodes = 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 sourceRoots = [];
|
||||||
let sourceConfig = {};
|
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)));
|
.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() {
|
async function loadSettings() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/config');
|
const r = await fetch('/api/config');
|
||||||
@@ -336,8 +429,14 @@ async function loadSettings() {
|
|||||||
document.getElementById('reserveGB').value = cfg.reserve_free_gb ?? 2;
|
document.getElementById('reserveGB').value = cfg.reserve_free_gb ?? 2;
|
||||||
document.getElementById('destFolder').value = cfg.dest_folder || 'media';
|
document.getElementById('destFolder').value = cfg.dest_folder || 'media';
|
||||||
document.getElementById('fileSelectMode').value = cfg.file_select_mode || 'new';
|
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('overwriteMode').value = cfg.overwrite_mode || 'skip';
|
||||||
document.getElementById('autoCopy').checked = !!cfg.auto_copy;
|
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 = {};
|
sourceConfig = {};
|
||||||
(cfg.sources || []).forEach((source) => {
|
(cfg.sources || []).forEach((source) => {
|
||||||
@@ -354,12 +453,15 @@ async function saveSettings(event) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
reserve_free_gb: parseFloat(document.getElementById('reserveGB').value) || 2,
|
reserve_free_gb: parseFloat(document.getElementById('reserveGB').value) || 2,
|
||||||
dest_folder: document.getElementById('destFolder').value.trim() || 'media',
|
dest_folder: document.getElementById('destFolder').value.trim() || 'media',
|
||||||
file_select_mode: document.getElementById('fileSelectMode').value,
|
file_select_mode: document.getElementById('fileSelectMode').value,
|
||||||
overwrite_mode: document.getElementById('overwriteMode').value,
|
allowed_files_mode: document.getElementById('allowedFilesMode').value,
|
||||||
auto_copy: document.getElementById('autoCopy').checked,
|
enabled_media_types: selectedMediaTypes(),
|
||||||
sources: collectSourcesForSave(),
|
allowed_extensions: parseExtensionsInput(document.getElementById('allowedExtensions').value),
|
||||||
|
overwrite_mode: document.getElementById('overwriteMode').value,
|
||||||
|
auto_copy: document.getElementById('autoCopy').checked,
|
||||||
|
sources: collectSourcesForSave(),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -405,6 +507,7 @@ document.getElementById('sourceTree').addEventListener('change', (event) => {
|
|||||||
toggleSource(checkbox.dataset.path, checkbox.checked);
|
toggleSource(checkbox.dataset.path, checkbox.checked);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
renderMediaTypeHints();
|
||||||
loadSettings();
|
loadSettings();
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user