Files
media-player-server/media_server/config_manager.py
alexei.dolgolyov 7c631d09f6 Add media browser feature with UI improvements
- 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>
2026-02-06 21:31:02 +03:00

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