- Refactored index.html: Split into separate HTML (309 lines), CSS (908 lines), and JS (1,286 lines) files - Implemented media browser with folder configuration, recursive navigation, and thumbnail display - Added metadata extraction using mutagen library (title, artist, album, duration, bitrate, codec) - Implemented thumbnail generation and caching with SHA256 hash-based keys and LRU eviction - Added platform-specific file playback (os.startfile on Windows, xdg-open on Linux, open on macOS) - Implemented path validation security to prevent directory traversal attacks - Added smooth thumbnail loading with fade-in animation and loading spinner - Added i18n support for browser (English and Russian) - Updated dependencies: mutagen>=1.47.0, pillow>=10.0.0 - Added comprehensive media browser documentation to README Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
393 lines
14 KiB
Python
393 lines
14 KiB
Python
"""Thread-safe configuration file manager for runtime script updates."""
|
|
|
|
import logging
|
|
import os
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import yaml
|
|
|
|
from .config import CallbackConfig, MediaFolderConfig, ScriptConfig, settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ConfigManager:
|
|
"""Thread-safe configuration file manager."""
|
|
|
|
def __init__(self, config_path: Optional[Path] = None):
|
|
"""Initialize the config manager.
|
|
|
|
Args:
|
|
config_path: Path to config file. If None, will search standard locations.
|
|
"""
|
|
self._lock = threading.Lock()
|
|
self._config_path = config_path or self._find_config_path()
|
|
logger.info(f"ConfigManager initialized with path: {self._config_path}")
|
|
|
|
def _find_config_path(self) -> Path:
|
|
"""Find the active config file path.
|
|
|
|
Returns:
|
|
Path to the config file.
|
|
|
|
Raises:
|
|
FileNotFoundError: If no config file is found.
|
|
"""
|
|
# Same search logic as Settings.load_from_yaml()
|
|
search_paths = [
|
|
Path("config.yaml"),
|
|
Path("config.yml"),
|
|
]
|
|
|
|
# Add platform-specific config directory
|
|
if os.name == "nt": # Windows
|
|
appdata = os.environ.get("APPDATA", "")
|
|
if appdata:
|
|
search_paths.append(Path(appdata) / "media-server" / "config.yaml")
|
|
else: # Linux/Unix/macOS
|
|
search_paths.append(Path.home() / ".config" / "media-server" / "config.yaml")
|
|
search_paths.append(Path("/etc/media-server/config.yaml"))
|
|
|
|
for search_path in search_paths:
|
|
if search_path.exists():
|
|
return search_path
|
|
|
|
# If not found, use the default location
|
|
if os.name == "nt":
|
|
default_path = Path(os.environ.get("APPDATA", "")) / "media-server" / "config.yaml"
|
|
else:
|
|
default_path = Path.home() / ".config" / "media-server" / "config.yaml"
|
|
|
|
logger.warning(f"No config file found, using default path: {default_path}")
|
|
return default_path
|
|
|
|
def add_script(self, name: str, config: ScriptConfig) -> None:
|
|
"""Add a new script to config.
|
|
|
|
Args:
|
|
name: Script name (must be unique).
|
|
config: Script configuration.
|
|
|
|
Raises:
|
|
ValueError: If script already exists.
|
|
IOError: If config file cannot be written.
|
|
"""
|
|
with self._lock:
|
|
# Read YAML
|
|
if not self._config_path.exists():
|
|
data = {}
|
|
else:
|
|
with open(self._config_path, "r", encoding="utf-8") as f:
|
|
data = yaml.safe_load(f) or {}
|
|
|
|
# Check if script already exists
|
|
if "scripts" in data and name in data["scripts"]:
|
|
raise ValueError(f"Script '{name}' already exists")
|
|
|
|
# Add script
|
|
if "scripts" not in data:
|
|
data["scripts"] = {}
|
|
data["scripts"][name] = config.model_dump(exclude_none=True)
|
|
|
|
# Write YAML
|
|
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(self._config_path, "w", encoding="utf-8") as f:
|
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
|
|
# Update in-memory settings
|
|
settings.scripts[name] = config
|
|
|
|
logger.info(f"Script '{name}' added to config")
|
|
|
|
def update_script(self, name: str, config: ScriptConfig) -> None:
|
|
"""Update an existing script.
|
|
|
|
Args:
|
|
name: Script name.
|
|
config: New script configuration.
|
|
|
|
Raises:
|
|
ValueError: If script does not exist.
|
|
IOError: If config file cannot be written.
|
|
"""
|
|
with self._lock:
|
|
# Read YAML
|
|
if not self._config_path.exists():
|
|
raise ValueError(f"Config file not found: {self._config_path}")
|
|
|
|
with open(self._config_path, "r", encoding="utf-8") as f:
|
|
data = yaml.safe_load(f) or {}
|
|
|
|
# Check if script exists
|
|
if "scripts" not in data or name not in data["scripts"]:
|
|
raise ValueError(f"Script '{name}' does not exist")
|
|
|
|
# Update script
|
|
data["scripts"][name] = config.model_dump(exclude_none=True)
|
|
|
|
# Write YAML
|
|
with open(self._config_path, "w", encoding="utf-8") as f:
|
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
|
|
# Update in-memory settings
|
|
settings.scripts[name] = config
|
|
|
|
logger.info(f"Script '{name}' updated in config")
|
|
|
|
def delete_script(self, name: str) -> None:
|
|
"""Delete a script from config.
|
|
|
|
Args:
|
|
name: Script name.
|
|
|
|
Raises:
|
|
ValueError: If script does not exist.
|
|
IOError: If config file cannot be written.
|
|
"""
|
|
with self._lock:
|
|
# Read YAML
|
|
if not self._config_path.exists():
|
|
raise ValueError(f"Config file not found: {self._config_path}")
|
|
|
|
with open(self._config_path, "r", encoding="utf-8") as f:
|
|
data = yaml.safe_load(f) or {}
|
|
|
|
# Check if script exists
|
|
if "scripts" not in data or name not in data["scripts"]:
|
|
raise ValueError(f"Script '{name}' does not exist")
|
|
|
|
# Delete script
|
|
del data["scripts"][name]
|
|
|
|
# Write YAML
|
|
with open(self._config_path, "w", encoding="utf-8") as f:
|
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
|
|
# Update in-memory settings
|
|
if name in settings.scripts:
|
|
del settings.scripts[name]
|
|
|
|
logger.info(f"Script '{name}' deleted from config")
|
|
|
|
def add_callback(self, name: str, config: CallbackConfig) -> None:
|
|
"""Add a new callback to config.
|
|
|
|
Args:
|
|
name: Callback name (must be unique).
|
|
config: Callback configuration.
|
|
|
|
Raises:
|
|
ValueError: If callback already exists.
|
|
IOError: If config file cannot be written.
|
|
"""
|
|
with self._lock:
|
|
# Read YAML
|
|
if not self._config_path.exists():
|
|
data = {}
|
|
else:
|
|
with open(self._config_path, "r", encoding="utf-8") as f:
|
|
data = yaml.safe_load(f) or {}
|
|
|
|
# Check if callback already exists
|
|
if "callbacks" in data and name in data["callbacks"]:
|
|
raise ValueError(f"Callback '{name}' already exists")
|
|
|
|
# Add callback
|
|
if "callbacks" not in data:
|
|
data["callbacks"] = {}
|
|
data["callbacks"][name] = config.model_dump(exclude_none=True)
|
|
|
|
# Write YAML
|
|
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(self._config_path, "w", encoding="utf-8") as f:
|
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
|
|
# Update in-memory settings
|
|
settings.callbacks[name] = config
|
|
|
|
logger.info(f"Callback '{name}' added to config")
|
|
|
|
def update_callback(self, name: str, config: CallbackConfig) -> None:
|
|
"""Update an existing callback.
|
|
|
|
Args:
|
|
name: Callback name.
|
|
config: New callback configuration.
|
|
|
|
Raises:
|
|
ValueError: If callback does not exist.
|
|
IOError: If config file cannot be written.
|
|
"""
|
|
with self._lock:
|
|
# Read YAML
|
|
if not self._config_path.exists():
|
|
raise ValueError(f"Config file not found: {self._config_path}")
|
|
|
|
with open(self._config_path, "r", encoding="utf-8") as f:
|
|
data = yaml.safe_load(f) or {}
|
|
|
|
# Check if callback exists
|
|
if "callbacks" not in data or name not in data["callbacks"]:
|
|
raise ValueError(f"Callback '{name}' does not exist")
|
|
|
|
# Update callback
|
|
data["callbacks"][name] = config.model_dump(exclude_none=True)
|
|
|
|
# Write YAML
|
|
with open(self._config_path, "w", encoding="utf-8") as f:
|
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
|
|
# Update in-memory settings
|
|
settings.callbacks[name] = config
|
|
|
|
logger.info(f"Callback '{name}' updated in config")
|
|
|
|
def delete_callback(self, name: str) -> None:
|
|
"""Delete a callback from config.
|
|
|
|
Args:
|
|
name: Callback name.
|
|
|
|
Raises:
|
|
ValueError: If callback does not exist.
|
|
IOError: If config file cannot be written.
|
|
"""
|
|
with self._lock:
|
|
# Read YAML
|
|
if not self._config_path.exists():
|
|
raise ValueError(f"Config file not found: {self._config_path}")
|
|
|
|
with open(self._config_path, "r", encoding="utf-8") as f:
|
|
data = yaml.safe_load(f) or {}
|
|
|
|
# Check if callback exists
|
|
if "callbacks" not in data or name not in data["callbacks"]:
|
|
raise ValueError(f"Callback '{name}' does not exist")
|
|
|
|
# Delete callback
|
|
del data["callbacks"][name]
|
|
|
|
# Write YAML
|
|
with open(self._config_path, "w", encoding="utf-8") as f:
|
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
|
|
# Update in-memory settings
|
|
if name in settings.callbacks:
|
|
del settings.callbacks[name]
|
|
|
|
logger.info(f"Callback '{name}' deleted from config")
|
|
|
|
def add_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None:
|
|
"""Add a new media folder to config.
|
|
|
|
Args:
|
|
folder_id: Folder ID (must be unique).
|
|
config: Media folder configuration.
|
|
|
|
Raises:
|
|
ValueError: If folder already exists.
|
|
IOError: If config file cannot be written.
|
|
"""
|
|
with self._lock:
|
|
# Read YAML
|
|
if not self._config_path.exists():
|
|
data = {}
|
|
else:
|
|
with open(self._config_path, "r", encoding="utf-8") as f:
|
|
data = yaml.safe_load(f) or {}
|
|
|
|
# Check if folder already exists
|
|
if "media_folders" in data and folder_id in data["media_folders"]:
|
|
raise ValueError(f"Media folder '{folder_id}' already exists")
|
|
|
|
# Add folder
|
|
if "media_folders" not in data:
|
|
data["media_folders"] = {}
|
|
data["media_folders"][folder_id] = config.model_dump(exclude_none=True)
|
|
|
|
# Write YAML
|
|
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(self._config_path, "w", encoding="utf-8") as f:
|
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
|
|
# Update in-memory settings
|
|
settings.media_folders[folder_id] = config
|
|
|
|
logger.info(f"Media folder '{folder_id}' added to config")
|
|
|
|
def update_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None:
|
|
"""Update an existing media folder.
|
|
|
|
Args:
|
|
folder_id: Folder ID.
|
|
config: New media folder configuration.
|
|
|
|
Raises:
|
|
ValueError: If folder does not exist.
|
|
IOError: If config file cannot be written.
|
|
"""
|
|
with self._lock:
|
|
# Read YAML
|
|
if not self._config_path.exists():
|
|
raise ValueError(f"Config file not found: {self._config_path}")
|
|
|
|
with open(self._config_path, "r", encoding="utf-8") as f:
|
|
data = yaml.safe_load(f) or {}
|
|
|
|
# Check if folder exists
|
|
if "media_folders" not in data or folder_id not in data["media_folders"]:
|
|
raise ValueError(f"Media folder '{folder_id}' does not exist")
|
|
|
|
# Update folder
|
|
data["media_folders"][folder_id] = config.model_dump(exclude_none=True)
|
|
|
|
# Write YAML
|
|
with open(self._config_path, "w", encoding="utf-8") as f:
|
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
|
|
# Update in-memory settings
|
|
settings.media_folders[folder_id] = config
|
|
|
|
logger.info(f"Media folder '{folder_id}' updated in config")
|
|
|
|
def delete_media_folder(self, folder_id: str) -> None:
|
|
"""Delete a media folder from config.
|
|
|
|
Args:
|
|
folder_id: Folder ID.
|
|
|
|
Raises:
|
|
ValueError: If folder does not exist.
|
|
IOError: If config file cannot be written.
|
|
"""
|
|
with self._lock:
|
|
# Read YAML
|
|
if not self._config_path.exists():
|
|
raise ValueError(f"Config file not found: {self._config_path}")
|
|
|
|
with open(self._config_path, "r", encoding="utf-8") as f:
|
|
data = yaml.safe_load(f) or {}
|
|
|
|
# Check if folder exists
|
|
if "media_folders" not in data or folder_id not in data["media_folders"]:
|
|
raise ValueError(f"Media folder '{folder_id}' does not exist")
|
|
|
|
# Delete folder
|
|
del data["media_folders"][folder_id]
|
|
|
|
# Write YAML
|
|
with open(self._config_path, "w", encoding="utf-8") as f:
|
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
|
|
# Update in-memory settings
|
|
if folder_id in settings.media_folders:
|
|
del settings.media_folders[folder_id]
|
|
|
|
logger.info(f"Media folder '{folder_id}' deleted from config")
|
|
|
|
|
|
# Global config manager instance
|
|
config_manager = ConfigManager()
|