From c9ee41ad356385a5754b46f62cbc0310864314b2 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 29 Mar 2026 14:44:03 +0300 Subject: [PATCH] feat: add media folder management from WebUI - Add media_folders_management config flag (enabled by default) - Guard folder CRUD endpoints with 403 when management disabled - Wire up frontend folder add/edit/delete in Settings tab - Add per-folder availability check (for network shares) - Show unavailable badge on offline folders in browser view - Expose management flag via /api/health endpoint - Add EN/RU locale keys for folder management UI --- config.example.yaml | 5 + media_server/config.py | 4 + media_server/routes/browser.py | 21 ++- media_server/routes/health.py | 2 + media_server/static/css/styles.css | 40 +++++- media_server/static/index.html | 33 +++++ media_server/static/js/app.js | 22 ++- media_server/static/js/browser.js | 206 +++++++++++++++++++++++++--- media_server/static/locales/en.json | 22 ++- media_server/static/locales/ru.json | 22 ++- 10 files changed, 352 insertions(+), 25 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 5033d88..4f573fa 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -56,6 +56,11 @@ scripts: timeout: 10 shell: true +# Media folder management from Web UI (default: true) +# When enabled, media folders can be added, edited, and deleted from the Settings tab. +# Set to false to disable folder management from the UI. +# media_folders_management: false + # Callback scripts (executed after media actions) # All callbacks are optional - if not defined, the action runs without callback callbacks: diff --git a/media_server/config.py b/media_server/config.py index 85d5f15..7edd4c8 100644 --- a/media_server/config.py +++ b/media_server/config.py @@ -124,6 +124,10 @@ class Settings(BaseSettings): default_factory=dict, description="Media folders available for browsing in the media browser", ) + media_folders_management: bool = Field( + default=True, + description="Allow adding, editing, and deleting media folders from the Web UI", + ) # Thumbnail settings thumbnail_size: str = Field( diff --git a/media_server/routes/browser.py b/media_server/routes/browser.py index 259fc93..a0b0edb 100644 --- a/media_server/routes/browser.py +++ b/media_server/routes/browser.py @@ -24,6 +24,15 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/browser", tags=["browser"]) +def _require_folder_management() -> None: + """Raise 403 if media folder management is disabled in config.""" + if not settings.media_folders_management: + raise HTTPException( + status_code=403, + detail="Media folder management is disabled. Set media_folders_management: true in config.yaml to enable.", + ) + + async def _broadcast_after_open(controller, label: str, max_wait: float = 2.0) -> None: """Poll until media session registers, then broadcast status update. @@ -83,17 +92,22 @@ async def list_folders(_: str = Depends(verify_token)): """List all configured media folders. Returns: - Dictionary of folder configurations. + Dictionary with folder configurations and management flag. """ folders = {} for folder_id, config in settings.media_folders.items(): + folder_path = Path(config.path) folders[folder_id] = { "id": folder_id, "label": config.label, "path": config.path, "enabled": config.enabled, + "available": folder_path.is_dir(), } - return folders + return { + "folders": folders, + "management_enabled": settings.media_folders_management, + } @router.post("/folders/create") @@ -112,6 +126,7 @@ async def create_folder( Raises: HTTPException: If folder already exists or validation fails. """ + _require_folder_management() try: # Validate folder_id format (alphanumeric and underscore only) if not request.folder_id.replace("_", "").isalnum(): @@ -169,6 +184,7 @@ async def update_folder( Raises: HTTPException: If folder doesn't exist or validation fails. """ + _require_folder_management() try: # Validate path exists path = Path(request.path) @@ -217,6 +233,7 @@ async def delete_folder( Raises: HTTPException: If folder doesn't exist. """ + _require_folder_management() try: config_manager.delete_media_folder(folder_id) diff --git a/media_server/routes/health.py b/media_server/routes/health.py index 1897dea..faee45e 100644 --- a/media_server/routes/health.py +++ b/media_server/routes/health.py @@ -7,6 +7,7 @@ from fastapi import APIRouter, Request from .. import __version__ from ..auth import auth_enabled +from ..config import settings router = APIRouter(prefix="/api", tags=["health"]) @@ -23,6 +24,7 @@ async def health_check(request: Request) -> dict[str, Any]: "platform": platform.system(), "version": __version__, "auth_required": auth_enabled(), + "media_folders_management": settings.media_folders_management, } # Include cached update info if available diff --git a/media_server/static/css/styles.css b/media_server/static/css/styles.css index 2829f30..53e7fc0 100644 --- a/media_server/static/css/styles.css +++ b/media_server/static/css/styles.css @@ -199,10 +199,48 @@ h1 { transition: background 0.3s; } -.status-dot.connected { +.status-dot.connected, +.status-dot.status-online { background: var(--accent); } +.status-dot.status-offline { + background: var(--error); +} + +/* Folder management */ +.folder-unavailable-badge, +.folder-disabled-badge { + font-size: 0.75rem; + padding: 1px 6px; + border-radius: 4px; + vertical-align: middle; + margin-left: 4px; +} + +.folder-unavailable-badge { + background: color-mix(in srgb, var(--error) 20%, transparent); + color: var(--error); +} + +.folder-disabled-badge { + background: color-mix(in srgb, var(--text-secondary) 20%, transparent); + color: var(--text-secondary); +} + +.browser-item.unavailable, +.browser-list-item.unavailable { + opacity: 0.5; + cursor: default; +} + +.path-cell { + max-width: 250px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .header-toolbar { display: flex; align-items: center; diff --git a/media_server/static/index.html b/media_server/static/index.html index 7a99352..c2c1962 100644 --- a/media_server/static/index.html +++ b/media_server/static/index.html @@ -323,6 +323,39 @@ + +
Scripts
diff --git a/media_server/static/js/app.js b/media_server/static/js/app.js index 7a65037..65617a1 100644 --- a/media_server/static/js/app.js +++ b/media_server/static/js/app.js @@ -57,6 +57,7 @@ import { onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged, downloadFile, closeFolderDialog, saveFolder, showManageFoldersDialog, + showAddFolderDialog, showEditFolderDialog, deleteFolderConfirm, } from './browser.js'; import { @@ -117,6 +118,7 @@ Object.assign(window, { onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged, downloadFile, closeFolderDialog, saveFolder, showManageFoldersDialog, + showAddFolderDialog, showEditFolderDialog, deleteFolderConfirm, // Links showAddLinkDialog, showEditLinkDialog, closeLinkDialog, saveLink, deleteLinkConfirm, @@ -323,6 +325,24 @@ window.addEventListener('DOMContentLoaded', async () => { else if (action === 'delete') deleteCallbackConfirm(name); }); + // Folder dialog backdrop click to close + const folderDialog = document.getElementById('folderDialog'); + folderDialog.addEventListener('click', (e) => { + if (e.target === folderDialog) { + closeFolderDialog(); + } + }); + + // Delegated click handlers for folder table actions + document.getElementById('foldersTableBody').addEventListener('click', (e) => { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + const action = btn.dataset.action; + const folderId = btn.dataset.folderId; + if (action === 'edit') showEditFolderDialog(folderId); + else if (action === 'delete') deleteFolderConfirm(folderId); + }); + // Link dialog backdrop click to close const linkDialog = document.getElementById('linkDialog'); linkDialog.addEventListener('click', (e) => { @@ -352,7 +372,7 @@ window.addEventListener('DOMContentLoaded', async () => { // Initialize browser toolbar and load folders initBrowserToolbar(); - if (token) { + if (!authReq || token) { loadMediaFolders(); } diff --git a/media_server/static/js/browser.js b/media_server/static/js/browser.js index d115a3d..5b06045 100644 --- a/media_server/static/js/browser.js +++ b/media_server/static/js/browser.js @@ -3,7 +3,7 @@ // ============================================================ import { - t, showToast, escapeHtml, closeDialog, + t, showToast, showConfirm, escapeHtml, closeDialog, SEARCH_DEBOUNCE_MS, EMPTY_SVG_FILE, EMPTY_SVG_FOLDER, emptyStateHtml, getAuthHeaders, hasCredentials, } from './core.js'; @@ -15,6 +15,7 @@ let currentOffset = 0; let itemsPerPage = parseInt(localStorage.getItem('mediaBrowser.itemsPerPage')) || 100; let totalItems = 0; let mediaFolders = {}; +let managementEnabled = false; let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid'; let cachedItems = null; let browserSearchTerm = ''; @@ -33,7 +34,20 @@ export async function loadMediaFolders() { if (!response.ok) throw new Error('Failed to load folders'); - mediaFolders = await response.json(); + const data = await response.json(); + mediaFolders = data.folders || {}; + managementEnabled = data.management_enabled || false; + + // Show/hide the media folders settings section + const section = document.getElementById('mediaFoldersSection'); + if (section) { + section.style.display = managementEnabled ? '' : 'none'; + } + + // Render folders table in settings if management is enabled + if (managementEnabled) { + loadFoldersTable(); + } // Load last browsed path or show root folder list loadLastBrowserPath(); @@ -78,32 +92,39 @@ function showRootFolders() { Object.entries(mediaFolders).forEach(([id, folder]) => { if (!folder.enabled) return; + const unavailable = folder.available === false; + const unavailableClass = unavailable ? ' unavailable' : ''; if (viewMode === 'list') { const row = document.createElement('div'); - row.className = 'browser-list-item'; - row.onclick = () => { - currentFolderId = id; - browsePath(id, ''); - }; + row.className = 'browser-list-item' + unavailableClass; + if (!unavailable) { + row.onclick = () => { + currentFolderId = id; + browsePath(id, ''); + }; + } row.innerHTML = `
\u{1F4C1}
-
${folder.label}
+
${escapeHtml(folder.label)}${unavailable ? ' ' + t('browser.unavailable') + '' : ''}
`; container.appendChild(row); } else { const card = document.createElement('div'); - card.className = 'browser-item'; - card.onclick = () => { - currentFolderId = id; - browsePath(id, ''); - }; + card.className = 'browser-item' + unavailableClass; + if (!unavailable) { + card.onclick = () => { + currentFolderId = id; + browsePath(id, ''); + }; + } card.innerHTML = `
\u{1F4C1}
-
${folder.label}
+
${escapeHtml(folder.label)}
+ ${unavailable ? '
' + t('browser.unavailable') + '
' : ''}
`; container.appendChild(card); @@ -845,10 +866,72 @@ function loadLastBrowserPath() { } } -// Folder Management -export function showManageFoldersDialog() { - // TODO: Implement folder management UI - showToast(t('browser.manage_folders_hint'), 'info'); +// Folder Management — Settings table + +export function loadFoldersTable() { + const tbody = document.getElementById('foldersTableBody'); + if (!tbody) return; + + const entries = Object.entries(mediaFolders); + if (entries.length === 0) { + tbody.innerHTML = ` +
+ +

${t('browser.folders_empty')}

+
`; + return; + } + + tbody.innerHTML = entries.map(([id, folder]) => { + const available = folder.available !== false; + const statusIcon = available + ? '' + : ''; + const enabledBadge = folder.enabled + ? '' + : ' ' + t('browser.folder_disabled') + ''; + return ` + ${escapeHtml(id)}${enabledBadge} + ${escapeHtml(folder.label)} + ${escapeHtml(folder.path)} + ${statusIcon} + + + + + `; + }).join(''); +} + +export function showAddFolderDialog() { + document.getElementById('folderDialogTitle').textContent = t('browser.folder_dialog.title_add'); + document.getElementById('folderIsEdit').value = ''; + document.getElementById('folderOriginalId').value = ''; + document.getElementById('folderId').value = ''; + document.getElementById('folderId').disabled = false; + document.getElementById('folderLabel').value = ''; + document.getElementById('folderPath').value = ''; + document.getElementById('folderEnabled').checked = true; + document.getElementById('folderDialog').showModal(); +} + +export function showEditFolderDialog(folderId) { + const folder = mediaFolders[folderId]; + if (!folder) return; + + document.getElementById('folderDialogTitle').textContent = t('browser.folder_dialog.title_edit'); + document.getElementById('folderIsEdit').value = '1'; + document.getElementById('folderOriginalId').value = folderId; + document.getElementById('folderId').value = folderId; + document.getElementById('folderId').disabled = true; + document.getElementById('folderLabel').value = folder.label; + document.getElementById('folderPath').value = folder.path; + document.getElementById('folderEnabled').checked = folder.enabled; + document.getElementById('folderDialog').showModal(); } export function closeFolderDialog() { @@ -857,5 +940,90 @@ export function closeFolderDialog() { export async function saveFolder(event) { event.preventDefault(); - closeFolderDialog(); + + const isEdit = document.getElementById('folderIsEdit').value === '1'; + const folderId = isEdit + ? document.getElementById('folderOriginalId').value + : document.getElementById('folderId').value.trim(); + const label = document.getElementById('folderLabel').value.trim(); + const path = document.getElementById('folderPath').value.trim(); + const enabled = document.getElementById('folderEnabled').checked; + + if (!folderId || !label || !path) return; + + const submitBtn = document.querySelector('#folderForm button[type="submit"]'); + if (submitBtn) submitBtn.disabled = true; + + try { + let response; + if (isEdit) { + response = await fetch(`/api/browser/folders/update/${encodeURIComponent(folderId)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, + body: JSON.stringify({ label, path, enabled }), + }); + } else { + response = await fetch('/api/browser/folders/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, + body: JSON.stringify({ folder_id: folderId, label, path, enabled }), + }); + } + + if (response.ok) { + closeFolderDialog(); + showToast(t(isEdit ? 'browser.folder_updated' : 'browser.folder_created'), 'success'); + await loadMediaFolders(); + } else { + const result = await response.json().catch(() => ({})); + showToast(result.detail || t('browser.folder_save_error'), 'error'); + } + } catch (error) { + console.error('Error saving folder:', error); + showToast(t('browser.folder_save_error'), 'error'); + } finally { + if (submitBtn) submitBtn.disabled = false; + } +} + +export async function deleteFolderConfirm(folderId) { + if (!await showConfirm(t('browser.folder_confirm_delete', { name: folderId }))) { + return; + } + + try { + const response = await fetch(`/api/browser/folders/delete/${encodeURIComponent(folderId)}`, { + method: 'DELETE', + headers: getAuthHeaders(), + }); + + if (response.ok) { + showToast(t('browser.folder_deleted'), 'success'); + await loadMediaFolders(); + } else { + const result = await response.json().catch(() => ({})); + showToast(result.detail || t('browser.folder_delete_error'), 'error'); + } + } catch (error) { + console.error('Error deleting folder:', error); + showToast(t('browser.folder_delete_error'), 'error'); + } +} + +// Legacy stub — now handled via settings table +export function showManageFoldersDialog() { + if (managementEnabled) { + // Switch to settings tab and scroll to the folders section + const switchTabFn = window.switchTab; + if (switchTabFn) switchTabFn('settings'); + setTimeout(() => { + const section = document.getElementById('mediaFoldersSection'); + if (section) { + section.setAttribute('open', ''); + section.scrollIntoView({ behavior: 'smooth' }); + } + }, 100); + } else { + showToast(t('browser.manage_folders_hint'), 'info'); + } } diff --git a/media_server/static/locales/en.json b/media_server/static/locales/en.json index 88876d6..4d5eb40 100644 --- a/media_server/static/locales/en.json +++ b/media_server/static/locales/en.json @@ -173,7 +173,27 @@ "browser.play_all_error": "Failed to play folder", "browser.error_loading": "Error loading directory", "browser.error_loading_folders": "Failed to load media folders", - "browser.manage_folders_hint": "Folder management coming soon! For now, edit config.yaml to add media folders.", + "browser.manage_folders_hint": "Folder management is disabled. Set media_folders_management: true in config.yaml to enable.", + "browser.unavailable": "Unavailable", + "browser.folder_available": "Available", + "browser.folder_unavailable": "Unavailable (path not reachable)", + "browser.folder_disabled": "disabled", + "browser.folder_edit": "Edit folder", + "browser.folder_delete": "Delete folder", + "browser.folder_created": "Media folder created successfully", + "browser.folder_updated": "Media folder updated successfully", + "browser.folder_deleted": "Media folder deleted successfully", + "browser.folder_save_error": "Failed to save media folder", + "browser.folder_delete_error": "Failed to delete media folder", + "browser.folder_confirm_delete": "Are you sure you want to delete the folder \"{name}\"?", + "browser.folders_description": "Media folders available for browsing. Folders on network shares show availability status.", + "browser.folders_empty": "No media folders configured. Click \"+\" to add one.", + "browser.folders_table.id": "ID", + "browser.folders_table.label": "Label", + "browser.folders_table.path": "Path", + "browser.folders_table.status": "Status", + "browser.folders_table.actions": "Actions", + "settings.section.media_folders": "Media Folders", "browser.folder_dialog.title_add": "Add Media Folder", "browser.folder_dialog.title_edit": "Edit Media Folder", "browser.folder_dialog.folder_id": "Folder ID *", diff --git a/media_server/static/locales/ru.json b/media_server/static/locales/ru.json index 844c330..6ff9e8e 100644 --- a/media_server/static/locales/ru.json +++ b/media_server/static/locales/ru.json @@ -173,7 +173,27 @@ "browser.play_all_error": "Не удалось воспроизвести папку", "browser.error_loading": "Ошибка загрузки каталога", "browser.error_loading_folders": "Не удалось загрузить медиа папки", - "browser.manage_folders_hint": "Управление папками скоро появится! Пока редактируйте config.yaml для добавления медиа папок.", + "browser.manage_folders_hint": "Управление папками отключено. Установите media_folders_management: true в config.yaml для включения.", + "browser.unavailable": "Недоступна", + "browser.folder_available": "Доступна", + "browser.folder_unavailable": "Недоступна (путь не найден)", + "browser.folder_disabled": "отключена", + "browser.folder_edit": "Редактировать папку", + "browser.folder_delete": "Удалить папку", + "browser.folder_created": "Медиа папка успешно создана", + "browser.folder_updated": "Медиа папка успешно обновлена", + "browser.folder_deleted": "Медиа папка успешно удалена", + "browser.folder_save_error": "Не удалось сохранить медиа папку", + "browser.folder_delete_error": "Не удалось удалить медиа папку", + "browser.folder_confirm_delete": "Вы уверены, что хотите удалить папку \"{name}\"?", + "browser.folders_description": "Медиа папки для просмотра. Для сетевых ресурсов показан статус доступности.", + "browser.folders_empty": "Медиа папки не настроены. Нажмите \"+\" для добавления.", + "browser.folders_table.id": "ID", + "browser.folders_table.label": "Метка", + "browser.folders_table.path": "Путь", + "browser.folders_table.status": "Статус", + "browser.folders_table.actions": "Действия", + "settings.section.media_folders": "Медиа папки", "browser.folder_dialog.title_add": "Добавить медиа папку", "browser.folder_dialog.title_edit": "Редактировать медиа папку", "browser.folder_dialog.folder_id": "ID папки *",