Add configurable allowed file types

This commit is contained in:
2026-04-24 16:36:48 +03:00
parent 50246ada85
commit 6953c151fe
7 changed files with 451 additions and 37 deletions

View File

@@ -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)

View File

@@ -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,
} }
} }

View File

@@ -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 == "" {

View 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
}

View File

@@ -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{})

View 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)
}
}

View File

@@ -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}}