media-rip/backend/app/core/config.py
xpltd 5a6eb00906 Docker self-hosting: fix persistence, add data_dir config
Critical fix:
- Dockerfile env var was MEDIARIP__DATABASE__PATH (ignored) — now MEDIARIP__SERVER__DB_PATH
  DB was landing at /app/mediarip.db (lost on restart) instead of /data/mediarip.db

Persistence model:
- /downloads → media files (bind mount recommended)
- /data → SQLite DB, session cookies, error logs (named volume)
- /themes → custom CSS themes (read-only bind mount)
- /app/config.yaml → optional YAML config (read-only bind mount)

Other changes:
- Add server.data_dir config field (default: /data) for explicit session storage
- Cookie storage uses data_dir instead of fragile path math from output_dir parent
- Lifespan creates data_dir on startup
- .dockerignore excludes tests, dev DB, egg-info
- docker-compose.yml: inline admin/purge config examples
- docker-compose.example.yml: parameterized with env vars
- .env.example: session mode, clearer docs
- README: Docker volumes table, admin setup docs, full config reference
- PROJECT.md: reflects completed v1.0 state
- REQUIREMENTS.md: all 26 requirements validated
2026-03-19 09:56:10 -05:00

147 lines
4.1 KiB
Python

"""Application configuration via pydantic-settings.
Loads settings from (highest → lowest priority):
1. Environment variables (prefix ``MEDIARIP``, nested delimiter ``__``)
2. YAML config file (optional — zero-config if missing)
3. Init kwargs
4. .env file
Zero-config mode: if no YAML file is provided or the file doesn't exist,
all settings fall back to sensible defaults.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from pydantic import BaseModel
from pydantic_settings import (
BaseSettings,
PydanticBaseSettingsSource,
SettingsConfigDict,
YamlConfigSettingsSource,
)
logger = logging.getLogger("mediarip.config")
# ---------------------------------------------------------------------------
# Nested config sections
# ---------------------------------------------------------------------------
class ServerConfig(BaseModel):
"""Core server settings."""
host: str = "0.0.0.0"
port: int = 8000
log_level: str = "info"
db_path: str = "mediarip.db"
data_dir: str = "/data"
class DownloadsConfig(BaseModel):
"""Download behaviour defaults."""
output_dir: str = "/downloads"
max_concurrent: int = 3
source_templates: dict[str, str] = {
"youtube.com": "%(uploader)s/%(title)s.%(ext)s",
"soundcloud.com": "%(uploader)s/%(title)s.%(ext)s",
"*": "%(title)s.%(ext)s",
}
default_template: str = "%(title)s.%(ext)s"
class SessionConfig(BaseModel):
"""Session management settings."""
mode: str = "isolated"
timeout_hours: int = 72
class PurgeConfig(BaseModel):
"""Automatic purge / cleanup settings."""
enabled: bool = False
max_age_hours: int = 168 # 7 days
cron: str = "0 3 * * *" # 3 AM daily
privacy_mode: bool = False
privacy_retention_hours: int = 24 # default when privacy mode enabled
class UIConfig(BaseModel):
"""UI preferences."""
default_theme: str = "dark"
welcome_message: str = "Paste any video or audio URL. We rip it, you download it. No accounts, no tracking."
class AdminConfig(BaseModel):
"""Admin panel settings."""
enabled: bool = False
username: str = "admin"
password_hash: str = ""
# ---------------------------------------------------------------------------
# Safe YAML source — tolerates missing files
# ---------------------------------------------------------------------------
class _SafeYamlSource(YamlConfigSettingsSource):
"""YAML source that returns an empty dict when the file is missing."""
def __call__(self) -> dict[str, Any]:
yaml_file = self.yaml_file_path
if yaml_file is None:
return {}
if not Path(yaml_file).is_file():
logger.debug("YAML config file not found at %s — using defaults", yaml_file)
return {}
return super().__call__()
# ---------------------------------------------------------------------------
# Root config
# ---------------------------------------------------------------------------
class AppConfig(BaseSettings):
"""Top-level application configuration.
Priority (highest wins): env vars → YAML file → init kwargs → .env file.
"""
model_config = SettingsConfigDict(
env_prefix="MEDIARIP__",
env_nested_delimiter="__",
yaml_file=None,
)
server: ServerConfig = ServerConfig()
downloads: DownloadsConfig = DownloadsConfig()
session: SessionConfig = SessionConfig()
purge: PurgeConfig = PurgeConfig()
ui: UIConfig = UIConfig()
admin: AdminConfig = AdminConfig()
themes_dir: str = "./themes"
@classmethod
def settings_customise_sources(
cls,
settings_cls: type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
return (
env_settings,
_SafeYamlSource(settings_cls),
init_settings,
dotenv_settings,
)