diff --git a/internal/api/handlers_sources.go b/internal/api/handlers_sources.go index 7e677e3..8e882ec 100644 --- a/internal/api/handlers_sources.go +++ b/internal/api/handlers_sources.go @@ -3,6 +3,7 @@ package api import ( "errors" "net/http" + "net/url" "os" "path/filepath" "sort" @@ -10,19 +11,20 @@ import ( ) func (s *Server) handleSources(w http.ResponseWriter, r *http.Request) { - absPath, err := normalizeSourcePathQuery(r.URL.Query().Get("path")) + relPath, err := normalizeSourcePath(r.URL.Query().Get("path")) if err != nil { jsonErr(w, http.StatusBadRequest, err.Error()) return } - if absPath == "" { - jsonOK(w, map[string]any{"path": "", "items": []map[string]string{}}) - return + + absPath := s.deps.Config.MediaPath + if relPath != "" { + absPath = filepath.Join(absPath, relPath) } entries, err := os.ReadDir(absPath) if err != nil { - jsonOK(w, map[string]any{"path": absPath, "items": []map[string]string{}}) + jsonOK(w, map[string]any{"path": relPath, "items": []map[string]string{}}) return } @@ -36,10 +38,13 @@ func (s *Server) handleSources(w http.ResponseWriter, r *http.Request) { if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { continue } - childPath := filepath.Join(absPath, e.Name()) + childPath := e.Name() + if relPath != "" { + childPath = filepath.Join(relPath, childPath) + } items = append(items, item{ Name: e.Name(), - Path: childPath, + Path: filepath.ToSlash(childPath), }) } @@ -48,18 +53,26 @@ func (s *Server) handleSources(w http.ResponseWriter, r *http.Request) { }) jsonOK(w, map[string]any{ - "path": absPath, + "path": relPath, "items": items, }) } -func normalizeSourcePathQuery(raw string) (string, error) { +func normalizeSourcePath(raw string) (string, error) { + raw, _ = url.QueryUnescape(raw) raw = strings.TrimSpace(raw) + raw = filepath.ToSlash(raw) + raw = strings.TrimPrefix(raw, "/") if raw == "" || raw == "." { return "", nil } + clean := filepath.Clean(raw) - if !filepath.IsAbs(clean) { + clean = filepath.ToSlash(clean) + if clean == "." { + return "", nil + } + if clean == ".." || strings.HasPrefix(clean, "../") { return "", errors.New("invalid source path") } return clean, nil diff --git a/internal/config/config.go b/internal/config/config.go index 94bb841..650d3e6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -116,25 +116,7 @@ func Save(path string, cfg *Config) error { func (c *Config) Validate() error { c.MediaPath = NormalizeMediaPath(c.MediaPath) - if c.MediaPath != "" { - info, err := os.Stat(c.MediaPath) - if err != nil { - return errors.New("media_path is not accessible") - } - if !info.IsDir() { - return errors.New("media_path must be a directory") - } - } c.Sources = NormalizeSources(c.Sources, c.MediaPath) - for _, source := range c.Sources { - info, err := os.Stat(source.Path) - if err != nil { - return errors.New("source path is not accessible: " + source.Path) - } - if !info.IsDir() { - return errors.New("source path must be a directory: " + source.Path) - } - } if c.ReserveFreeGB < 0 { return errors.New("reserve_free_gb must be >= 0") } diff --git a/internal/copier/copier.go b/internal/copier/copier.go index bff5e3c..85b95e2 100644 --- a/internal/copier/copier.go +++ b/internal/copier/copier.go @@ -392,9 +392,15 @@ func (c *Copier) processVideo(ctx context.Context, taskID string, database *db.D ext := transcoder.OutputExt(profile.OutputFormat) dstTranscoded := strings.TrimSuffix(dst, filepath.Ext(dst)) + ext + srcFPS := fmt.Sprintf("%.2f", info.FPS) + msg := fmt.Sprintf("Transcoding %s (%s/%dch/%sfps → %s/%s/%dfps %s)", + filepath.Base(src), + info.Codec, info.AudioChannels, srcFPS, + profile.VideoCodec, profile.AudioCodec, profile.MaxFPS, profile.OutputFormat, + ) c.tasks.Update(taskID, func(t *task.Task) { t.Phase = task.PhaseTranscoding - t.Message = "Transcoding " + filepath.Base(src) + t.Message = msg }) if t, ok := c.tasks.Get(taskID); ok { _ = database.UpdateTask(*t) diff --git a/internal/transcoder/transcoder.go b/internal/transcoder/transcoder.go index d623b3a..5ea48f4 100644 --- a/internal/transcoder/transcoder.go +++ b/internal/transcoder/transcoder.go @@ -97,8 +97,14 @@ func buildArgs(opts Options) []string { if maxH := maxHeight(p.MaxResolution); maxH > 0 { filters = append(filters, fmt.Sprintf("scale=-2:min(%d\\,ih)", maxH)) } - if p.MaxFPS > 0 && src.FPS > float64(p.MaxFPS)+0.01 { - filters = append(filters, fmt.Sprintf("fps=%d", p.MaxFPS)) + if p.MaxFPS > 0 { + targetFPS := p.MaxFPS + if src.FPS > 0 && src.FPS < float64(targetFPS) { + // Источник медленнее лимита — сохраняем исходный FPS + filters = append(filters, fmt.Sprintf("fps=%.3f", src.FPS)) + } else { + filters = append(filters, fmt.Sprintf("fps=%d", targetFPS)) + } } if len(filters) > 0 { args = append(args, "-vf", strings.Join(filters, ",")) diff --git a/web/templates/settings.html b/web/templates/settings.html index a275531..18ea919 100644 --- a/web/templates/settings.html +++ b/web/templates/settings.html @@ -4,18 +4,16 @@

Copy Sources

-
Add one or more root folders with source files. After that, expand each root and enable or disable individual nested folders with checkboxes.
-
-
- - - +
Select top-level folders or expand branches and choose individual nested directories.
-
No source folders added yet.
+
Loading...
+
+ +
@@ -124,7 +122,6 @@ const builtInMediaTypes = { 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 = {}; let allowedFilesMode = 'media_types'; @@ -138,43 +135,13 @@ function escapeHTML(value) { }[char])); } -function pathSegments(path) { - return String(path || '').split(/[\\/]+/).filter(Boolean); -} - -function nodeName(path) { - const parts = pathSegments(path); - return parts.length ? parts[parts.length - 1] : path; -} - -function normalizeComparePath(path) { - return String(path || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(); -} - -function isSamePath(a, b) { - return normalizeComparePath(a) === normalizeComparePath(b); -} - -function isPathWithin(base, candidate) { - const baseNorm = normalizeComparePath(base); - const candidateNorm = normalizeComparePath(candidate); - return candidateNorm === baseNorm || candidateNorm.startsWith(baseNorm + '/'); +function pathDepth(path) { + return path ? path.split('/').length : 0; } function parentPath(path) { - const value = String(path || '').replace(/[\\/]+$/, ''); - const slash = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')); - if (slash < 0) return ''; - if (slash === 2 && /^[A-Za-z]:/.test(value)) return value.slice(0, slash + 1); - if (slash === 0) return value.slice(0, 1); - return value.slice(0, slash); -} - -function relativeDepth(root, path) { - if (isSamePath(root, path)) return 0; - const rootParts = pathSegments(root); - const pathParts = pathSegments(path); - return Math.max(0, pathParts.length - rootParts.length); + if (!path || !path.includes('/')) return ''; + return path.slice(0, path.lastIndexOf('/')); } function effectiveSourceState(path) { @@ -183,36 +150,37 @@ function effectiveSourceState(path) { if (Object.prototype.hasOwnProperty.call(sourceConfig, current)) { return sourceConfig[current]; } - current = parentPath(current); if (!current) return true; + current = parentPath(current); } } function collectSourcesForSave() { const items = []; const seen = new Set(); + const roots = sourceTree.get('') || []; - sourceRoots.forEach((root) => { - items.push({ path: root, enabled: effectiveSourceState(root), root: true }); - seen.add(normalizeComparePath(root)); - }); + for (const item of roots) { + items.push({ path: item.path, enabled: effectiveSourceState(item.path) }); + seen.add(item.path); + } Object.entries(sourceConfig).forEach(([path, enabled]) => { - const key = normalizeComparePath(path); - if (seen.has(key)) return; - items.push({ path, enabled, root: false }); + if (seen.has(path)) return; + items.push({ path, enabled }); }); return items.sort((a, b) => a.path.localeCompare(b.path)); } -async function loadSourceChildren(path) { - if (!path || loadingNodes.has(path)) return; +async function loadSourceChildren(path = '') { + if (loadingNodes.has(path)) return; loadingNodes.add(path); renderSources(); try { - const response = await fetch('/api/sources?path=' + encodeURIComponent(path)); + const query = path ? '?path=' + encodeURIComponent(path) : ''; + const response = await fetch('/api/sources' + query); if (!response.ok) return; const payload = await response.json(); sourceTree.set(path, payload.items || []); @@ -237,57 +205,15 @@ function toggleSource(path, checked) { renderSources(); } -function removeRoot(path) { - sourceRoots = sourceRoots.filter((root) => !isSamePath(root, path)); - sourceTree.delete(path); - expandedNodes.delete(path); - loadingNodes.delete(path); - - Object.keys(sourceConfig).forEach((key) => { - if (isPathWithin(path, key)) { - delete sourceConfig[key]; - } - }); - - renderSources(); -} - -async function addSourceRoot() { - const input = document.getElementById('newSourcePath'); - const path = (input?.value || '').trim(); - if (!path) return; - if (sourceRoots.some((root) => isSamePath(root, path))) { - toast('This source folder is already added', 'error'); - return; - } - sourceRoots.push(path); - sourceRoots.sort((a, b) => a.localeCompare(b)); - sourceConfig[path] = true; - expandedNodes.add(path); - if (input) input.value = ''; - await loadSourceChildren(path); -} - -async function reloadAllSourceTrees() { - const roots = [...sourceRoots]; - sourceTree.clear(); - for (const root of roots) { - if (expandedNodes.has(root)) { - await loadSourceChildren(root); - } - } - renderSources(); -} - -function renderSourceNodes(root, parentPathValue) { - const items = sourceTree.get(parentPathValue) || []; +function renderSourceNodes(parent = '') { + const items = sourceTree.get(parent) || []; return items.map((item) => { const checked = effectiveSourceState(item.path); const expanded = expandedNodes.has(item.path); const childrenKnown = sourceTree.has(item.path); const children = childrenKnown ? sourceTree.get(item.path) : []; const hasChildren = !childrenKnown || children.length > 0; - const pad = 16 + (relativeDepth(root, item.path) + 1) * 20; + const pad = 16 + pathDepth(item.path) * 20; return `
@@ -302,11 +228,11 @@ function renderSourceNodes(root, parentPathValue) {
${escapeHTML(item.name)} - ${escapeHTML(item.path)} + /media/${escapeHTML(item.path)}
${expanded && loadingNodes.has(item.path) ? '
Loading...
' : ''} - ${expanded && childrenKnown && children.length ? `
${renderSourceNodes(root, item.path)}
` : ''} + ${expanded && childrenKnown && children.length ? `
${renderSourceNodes(item.path)}
` : ''} `; }).join(''); @@ -314,54 +240,24 @@ function renderSourceNodes(root, parentPathValue) { function renderSources() { const el = document.getElementById('sourceTree'); - if (!sourceRoots.length) { - el.innerHTML = '
No source folders added yet.
'; + const roots = sourceTree.get(''); + + if (loadingNodes.has('') && !roots) { + el.innerHTML = '
Loading...
'; + return; + } + if (!roots || !roots.length) { + el.innerHTML = '
No folders found in /media.
'; return; } - el.innerHTML = sourceRoots.map((root) => { - const checked = effectiveSourceState(root); - const expanded = expandedNodes.has(root); - const childrenKnown = sourceTree.has(root); - const children = childrenKnown ? sourceTree.get(root) : []; - const hasChildren = !childrenKnown || children.length > 0; - - return ` -
-
- - -
-
- ${escapeHTML(nodeName(root))} - Root -
- ${escapeHTML(root)} -
- -
- ${expanded && loadingNodes.has(root) ? '
Loading...
' : ''} - ${expanded && childrenKnown && children.length ? `
${renderSourceNodes(root, root)}
` : ''} -
- `; - }).join(''); + el.innerHTML = renderSourceNodes(''); } -function deriveRootsFromSources(sources) { - const explicitRoots = sources.filter((source) => source.root).map((source) => source.path); - if (explicitRoots.length) { - return explicitRoots; - } - - return sources - .map((source) => source.path) - .filter((path, index, all) => !all.some((other, otherIndex) => otherIndex !== index && isPathWithin(other, path) && !isSamePath(other, path))); +async function reloadSourceTree() { + sourceTree.clear(); + expandedNodes.clear(); + await loadSourceChildren(''); } function defaultAllowedExtensions() { @@ -435,10 +331,7 @@ async function loadSettings() { (cfg.sources || []).forEach((source) => { sourceConfig[source.path] = !!source.enabled; }); - sourceRoots = deriveRootsFromSources(cfg.sources || []).sort((a, b) => a.localeCompare(b)); - expandedNodes.clear(); - sourceTree.clear(); - await reloadAllSourceTrees(); + renderSources(); } catch (error) {} } @@ -446,15 +339,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, + 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: allowedFilesMode, 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(), + overwrite_mode: document.getElementById('overwriteMode').value, + auto_copy: document.getElementById('autoCopy').checked, + sources: collectSourcesForSave(), }; try { @@ -476,22 +369,16 @@ async function saveSettings(event) { } document.getElementById('sourceTree').addEventListener('click', async (event) => { - const button = event.target.closest('button[data-action]'); + const button = event.target.closest('[data-action="toggle-expand"]'); if (!button) return; - const action = button.dataset.action; const path = button.dataset.path; - if (action === 'toggle-expand') { - if (expandedNodes.has(path)) { - expandedNodes.delete(path); - renderSources(); - return; - } - await ensureExpanded(path); - } - if (action === 'remove-root') { - removeRoot(path); + if (expandedNodes.has(path)) { + expandedNodes.delete(path); + renderSources(); + return; } + await ensureExpanded(path); }); document.getElementById('sourceTree').addEventListener('change', (event) => { @@ -502,5 +389,6 @@ document.getElementById('sourceTree').addEventListener('change', (event) => { renderMediaTypeHints(); loadSettings(); +loadSourceChildren(''); {{end}}