2026-06-19 12:22:36 +02:00
|
|
|
"""Admin-editable system configuration: a DB-override layer over the env/config defaults.
|
|
|
|
|
|
|
|
|
|
The registry (SPECS) is the single source of truth for which config keys can be overridden
|
|
|
|
|
from the admin Configuration page, plus each key's type, admin-UI group, env/config default,
|
|
|
|
|
bounds, and whether it's a secret. The resolver returns the DB override if one is set, else
|
|
|
|
|
``settings.<default_attr>`` (same DB-override-or-default pattern as app.state). Secrets are
|
|
|
|
|
stored Fernet-encrypted at rest (requires TOKEN_ENCRYPTION_KEY; without it, secrets can't be
|
|
|
|
|
stored in the DB and stay env-only).
|
|
|
|
|
"""
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
|
|
|
from app import security
|
|
|
|
|
from app.config import settings
|
|
|
|
|
from app.models import SystemConfig
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class ConfigSpec:
|
|
|
|
|
key: str
|
|
|
|
|
type: str # "int" | "str" | "bool"
|
|
|
|
|
group: str # admin-UI grouping (logically related keys stay together)
|
|
|
|
|
default_attr: str # attribute on `settings` holding the env/config default
|
|
|
|
|
secret: bool = False
|
|
|
|
|
min: int | None = None
|
|
|
|
|
max: int | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Registry of DB-overridable keys. Add a key here + wire its read through get_*() to move it
|
|
|
|
|
# off env into the admin UI; the admin page renders it automatically from this list.
|
|
|
|
|
SPECS: tuple[ConfigSpec, ...] = (
|
|
|
|
|
# --- Email / SMTP (one logical group; the password is the only secret) ---
|
|
|
|
|
ConfigSpec("smtp_host", "str", "email", "smtp_host"),
|
|
|
|
|
ConfigSpec("smtp_port", "int", "email", "smtp_port", min=1, max=65535),
|
|
|
|
|
ConfigSpec("smtp_user", "str", "email", "smtp_user"),
|
|
|
|
|
ConfigSpec("smtp_from", "str", "email", "smtp_from"),
|
|
|
|
|
ConfigSpec("smtp_password", "str", "email", "smtp_password", secret=True),
|
feat(config): move operational params to DB (quota/backfill/shorts/batch)
Register 8 more keys in the sysconfig registry (quota daily budget + backfill
reserve, recent-backfill window, shorts-probe params, enrich/autotag batch sizes)
and route their reads through the DB-override-or-env resolver at every call site
(quota.py, sync/runner.py, sync/videos.py, sync/autotag.py, sync/maintenance.py,
routes/scheduler.py). All read sites already had a db session in scope. Reads are
read-through (no cache), matching app.state; admin can tune them live, no redeploy.
2026-06-19 13:07:45 +02:00
|
|
|
# --- Quota (shared YouTube Data API daily budget) ---
|
|
|
|
|
ConfigSpec("quota_daily_budget", "int", "quota", "quota_daily_budget", min=1, max=1_000_000),
|
|
|
|
|
ConfigSpec("backfill_quota_reserve", "int", "quota", "backfill_quota_reserve", min=0, max=1_000_000),
|
2026-06-29 02:01:31 +02:00
|
|
|
ConfigSpec("search_daily_limit_per_user", "int", "quota", "search_daily_limit_per_user", min=0, max=100_000),
|
2026-06-29 22:29:54 +02:00
|
|
|
# Live-search source: "scrape" (InnerTube, zero API quota) or "api" (search.list, 100u/page).
|
|
|
|
|
ConfigSpec("search_source", "str", "quota", "search_source"),
|
feat(config): move operational params to DB (quota/backfill/shorts/batch)
Register 8 more keys in the sysconfig registry (quota daily budget + backfill
reserve, recent-backfill window, shorts-probe params, enrich/autotag batch sizes)
and route their reads through the DB-override-or-env resolver at every call site
(quota.py, sync/runner.py, sync/videos.py, sync/autotag.py, sync/maintenance.py,
routes/scheduler.py). All read sites already had a db session in scope. Reads are
read-through (no cache), matching app.state; admin can tune them live, no redeploy.
2026-06-19 13:07:45 +02:00
|
|
|
# --- Backfill (recent-first first pass per channel) ---
|
|
|
|
|
ConfigSpec("backfill_recent_max_videos", "int", "backfill", "backfill_recent_max_videos", min=1, max=100_000),
|
|
|
|
|
ConfigSpec("backfill_recent_max_days", "int", "backfill", "backfill_recent_max_days", min=1, max=36_500),
|
|
|
|
|
# --- Shorts probe ---
|
|
|
|
|
ConfigSpec("shorts_probe_max_seconds", "int", "shorts", "shorts_probe_max_seconds", min=1, max=3_600),
|
|
|
|
|
ConfigSpec("shorts_probe_batch", "int", "shorts", "shorts_probe_batch", min=1, max=10_000),
|
|
|
|
|
# --- Batch sizes ---
|
|
|
|
|
ConfigSpec("enrich_batch_size", "int", "batch", "enrich_batch_size", min=1, max=50),
|
|
|
|
|
ConfigSpec("autotag_title_sample", "int", "batch", "autotag_title_sample", min=1, max=1_000),
|
2026-07-01 00:42:32 +02:00
|
|
|
# --- Discovery cleanup (search + explore ephemeral content) ---
|
feat(channels): channel-explore backend — about metadata, ephemeral provenance, cleanup
Adds the backend for a dedicated channel page + ephemeral browsing of un-subscribed
channels:
- migration 0033: channels.{total_view_count,published_at,banner_url,external_links,
from_explore}, videos.via_explore, explored_channels (per-user, grace-clocked).
- enrich channels with part=brandingSettings (banner/links) + statistics.viewCount +
snippet.publishedAt — no extra quota.
- GET /api/channels/{id}: About detail + this user's relationship; lazy-enriches the new
About fields (published_at sentinel).
- POST /api/channels/{id}/explore (require_human, quota-reserve guarded): records the
exploration, flags the channel ephemeral unless followed, ingests one page of uploads
(via_explore) + enriches; returns next_page_token for load-more.
- feed: per-explorer gate — a via_explore video is visible only to users who explored or
subscribe to its channel, so exploration never leaks into everyone's catalog.
- subscribe = keep: clears from_explore + via_explore on the channel's videos.
- scheduler explore_cleanup job + explore_grace_days config: hard-delete explored-but-
unkept channels and their untouched ephemeral videos after a grace period.
2026-06-30 02:52:44 +02:00
|
|
|
# Days an explored-but-unsubscribed channel is kept before the cleanup job purges it.
|
|
|
|
|
ConfigSpec("explore_grace_days", "int", "explore", "explore_grace_days", min=0, max=3_650),
|
2026-07-01 00:42:32 +02:00
|
|
|
# Days an un-kept live-search result video is kept before the cleanup job removes it.
|
|
|
|
|
ConfigSpec("search_grace_days", "int", "explore", "search_grace_days", min=0, max=3_650),
|
2026-06-19 13:19:06 +02:00
|
|
|
# --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) ---
|
|
|
|
|
ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True),
|
2026-06-26 00:32:17 +02:00
|
|
|
# Optional egress proxy for YouTube API calls (fixed-IP host for an IP-restricted key).
|
|
|
|
|
ConfigSpec("youtube_api_proxy", "str", "youtube", "youtube_api_proxy"),
|
2026-06-20 20:04:23 +02:00
|
|
|
# --- Google OAuth client (Sign in with Google; both encrypted, env fallback). Set from the
|
|
|
|
|
# install wizard / Configuration page so no restart is needed when the creds change. ---
|
|
|
|
|
ConfigSpec("google_client_id", "str", "google", "google_client_id", secret=True),
|
|
|
|
|
ConfigSpec("google_client_secret", "str", "google", "google_client_secret", secret=True),
|
2026-06-19 14:03:11 +02:00
|
|
|
# --- Access / registration ---
|
|
|
|
|
ConfigSpec("allow_registration", "bool", "access", "allow_registration"),
|
feat(downloads): M1 — schema, config, worker container, deps
- models: MediaAsset (shared file cache, unique on source+format_sig), DownloadJob
(per-user request), DownloadProfile (presets), DownloadShare (ACL), DownloadQuota
- migrations 0036 (5 tables) + 0037 (6 builtin presets)
- sysconfig 'downloads' group (per-user defaults, retention/GC, layout, worker concurrency)
- config: DOWNLOAD_ROOT / WORKER_ENABLED env + download defaults
- deps: yt-dlp in requirements, ffmpeg in Dockerfile runtime stage
- worker: dedicated container (python -m app.worker), thin idle entry (loop lands in M2)
- localdev compose: worker service + downloads bind-mount; gitignore downloads/
2026-07-02 23:57:40 +02:00
|
|
|
# --- Download center (yt-dlp) — per-user defaults + retention/GC + layout ---
|
feat(downloads): M3 — per-user quota + retention GC
- downloads/quota.py: per-user limits (DownloadQuota row or sysconfig defaults; unlimited
bypass), footprint = distinct ready-asset bytes the user holds, check_enqueue (max_jobs +
max_bytes hard at enqueue), at_concurrency_limit (worker-side max_concurrent), usage snapshot
- downloads/gc.py: run_download_gc — pre-expiry warning (once, gc_notified flag), TTL deletion
(files + row; FK SET NULL orphans holders' jobs -> 'expired'), LRU eviction over a total-cache
cap; notifies holders on each event
- migration 0038: media_assets.gc_notified
- config/sysconfig: download_total_max_bytes (0=unlimited) in the downloads group
- service.enqueue enforces quota; worker claim skips users at their concurrency limit
- scheduler: download_gc job registered (default 360 min, admin-tunable)
Verified on localdev: footprint accounting, max_jobs block, pre-expiry warning (notifies all
holders), TTL deletion (asset+files gone, dirs pruned, jobs orphaned, holders notified).
2026-07-03 00:26:40 +02:00
|
|
|
ConfigSpec("download_total_max_bytes", "int", "downloads", "download_total_max_bytes", min=0),
|
feat(downloads): M1 — schema, config, worker container, deps
- models: MediaAsset (shared file cache, unique on source+format_sig), DownloadJob
(per-user request), DownloadProfile (presets), DownloadShare (ACL), DownloadQuota
- migrations 0036 (5 tables) + 0037 (6 builtin presets)
- sysconfig 'downloads' group (per-user defaults, retention/GC, layout, worker concurrency)
- config: DOWNLOAD_ROOT / WORKER_ENABLED env + download defaults
- deps: yt-dlp in requirements, ffmpeg in Dockerfile runtime stage
- worker: dedicated container (python -m app.worker), thin idle entry (loop lands in M2)
- localdev compose: worker service + downloads bind-mount; gitignore downloads/
2026-07-02 23:57:40 +02:00
|
|
|
ConfigSpec("download_default_max_bytes", "int", "downloads", "download_default_max_bytes", min=0),
|
|
|
|
|
ConfigSpec("download_default_max_concurrent", "int", "downloads", "download_default_max_concurrent", min=1, max=20),
|
|
|
|
|
ConfigSpec("download_default_max_jobs", "int", "downloads", "download_default_max_jobs", min=1, max=100_000),
|
|
|
|
|
ConfigSpec("download_retention_days", "int", "downloads", "download_retention_days", min=0, max=3_650),
|
|
|
|
|
ConfigSpec("download_gc_grace_days", "int", "downloads", "download_gc_grace_days", min=0, max=3_650),
|
|
|
|
|
ConfigSpec("download_layout", "str", "downloads", "download_layout"),
|
|
|
|
|
ConfigSpec("download_worker_concurrency", "int", "downloads", "download_worker_concurrency", min=1, max=16),
|
|
|
|
|
ConfigSpec("sponsorblock_default", "bool", "downloads", "sponsorblock_default"),
|
feat(plex): P0 backend foundations — catalog model, config, client
Optional Plex module groundwork (design locked 2026-07-05): plays the LOCAL
physical file; Plex is metadata + optional watch-sync only.
- models + migration 0044: plex_libraries/plex_shows/plex_seasons/plex_items
(playable leaf, ffprobe playability class, markers, weighted FTS search_vector)
+ plex_states (per-user watch state, mirrors video_states)
- sysconfig 'plex' group + config.py env defaults (server url/token[secret]/
path-map/libraries/sync-interval/max-transcodes)
- app/plex/client.py (PlexClient httpx: server info, sections, items, metadata
+markers, image) + app/plex/paths.py (Plex→local path map + file resolve)
- routes/plex.py admin POST /api/plex/test (verify connection + list sections)
2026-07-05 01:35:08 +02:00
|
|
|
# --- Plex integration (optional; local-file playback + Plex metadata) ---
|
|
|
|
|
ConfigSpec("plex_enabled", "bool", "plex", "plex_enabled"),
|
|
|
|
|
ConfigSpec("plex_server_url", "str", "plex", "plex_server_url"),
|
|
|
|
|
ConfigSpec("plex_server_token", "str", "plex", "plex_server_token", secret=True),
|
|
|
|
|
ConfigSpec("plex_path_map", "str", "plex", "plex_path_map"),
|
|
|
|
|
ConfigSpec("plex_libraries", "str", "plex", "plex_libraries"),
|
|
|
|
|
ConfigSpec("plex_sync_interval_min", "int", "plex", "plex_sync_interval_min", min=5, max=100_000),
|
|
|
|
|
ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16),
|
2026-06-19 12:22:36 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
_BY_KEY: dict[str, ConfigSpec] = {s.key: s for s in SPECS}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def spec(key: str) -> ConfigSpec | None:
|
|
|
|
|
return _BY_KEY.get(key)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def secrets_manageable() -> bool:
|
|
|
|
|
"""Secret values can only be stored when Fernet is configured (TOKEN_ENCRYPTION_KEY)."""
|
|
|
|
|
return bool(settings.token_encryption_key)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _default(s: ConfigSpec):
|
|
|
|
|
return getattr(settings, s.default_attr)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _coerce(s: ConfigSpec, raw: str):
|
|
|
|
|
if s.type == "int":
|
|
|
|
|
return int(raw)
|
|
|
|
|
if s.type == "bool":
|
|
|
|
|
return raw == "true"
|
|
|
|
|
return raw
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _raw_override(db: Session, key: str) -> str | None:
|
|
|
|
|
"""Stored (decrypted) override string for `key`, or None if no override is set."""
|
|
|
|
|
row = db.get(SystemConfig, key)
|
|
|
|
|
if row is None or row.value is None:
|
|
|
|
|
return None
|
|
|
|
|
return security.decrypt(row.value) if row.encrypted else row.value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get(db: Session, key: str):
|
|
|
|
|
"""Effective value: the DB override (typed) if set, else the env/config default."""
|
|
|
|
|
s = _BY_KEY[key]
|
|
|
|
|
raw = _raw_override(db, key)
|
|
|
|
|
if raw is None or raw == "":
|
|
|
|
|
return _default(s)
|
|
|
|
|
try:
|
|
|
|
|
return _coerce(s, raw)
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
return _default(s)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_str(db: Session, key: str) -> str:
|
|
|
|
|
v = get(db, key)
|
|
|
|
|
return "" if v is None else str(v)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_int(db: Session, key: str) -> int:
|
|
|
|
|
return int(get(db, key))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_bool(db: Session, key: str) -> bool:
|
|
|
|
|
return bool(get(db, key))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def set_value(db: Session, key: str, value) -> None:
|
|
|
|
|
"""Validate, coerce, and persist a DB override for `key`. Secrets are encrypted."""
|
|
|
|
|
s = _BY_KEY[key]
|
|
|
|
|
if s.type == "int":
|
|
|
|
|
v = int(value)
|
|
|
|
|
if s.min is not None and v < s.min:
|
|
|
|
|
raise ValueError(f"{key} must be >= {s.min}")
|
|
|
|
|
if s.max is not None and v > s.max:
|
|
|
|
|
raise ValueError(f"{key} must be <= {s.max}")
|
|
|
|
|
raw = str(v)
|
|
|
|
|
elif s.type == "bool":
|
|
|
|
|
raw = "true" if value else "false"
|
|
|
|
|
else:
|
|
|
|
|
raw = "" if value is None else str(value)
|
|
|
|
|
|
|
|
|
|
stored, encrypted = raw, False
|
|
|
|
|
if s.secret:
|
|
|
|
|
if not secrets_manageable():
|
|
|
|
|
raise RuntimeError("TOKEN_ENCRYPTION_KEY is not configured")
|
|
|
|
|
stored, encrypted = security.encrypt(raw) or "", True
|
|
|
|
|
|
|
|
|
|
row = db.get(SystemConfig, key)
|
|
|
|
|
if row is None:
|
|
|
|
|
row = SystemConfig(key=key)
|
|
|
|
|
db.add(row)
|
|
|
|
|
row.value = stored
|
|
|
|
|
row.encrypted = encrypted
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def reset(db: Session, key: str) -> None:
|
|
|
|
|
"""Remove the DB override for `key` (fall back to the env/config default)."""
|
|
|
|
|
row = db.get(SystemConfig, key)
|
|
|
|
|
if row is not None:
|
|
|
|
|
db.delete(row)
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def describe(db: Session) -> dict:
|
|
|
|
|
"""Admin-UI metadata + current values, grouped. Secret values are never echoed —
|
|
|
|
|
only whether an override is set and whether an env default exists."""
|
|
|
|
|
groups: dict[str, list[dict]] = {}
|
|
|
|
|
for s in SPECS:
|
|
|
|
|
is_set = db.get(SystemConfig, s.key) is not None
|
|
|
|
|
item: dict = {
|
|
|
|
|
"key": s.key,
|
|
|
|
|
"type": s.type,
|
|
|
|
|
"group": s.group,
|
|
|
|
|
"secret": s.secret,
|
|
|
|
|
"min": s.min,
|
|
|
|
|
"max": s.max,
|
|
|
|
|
"is_set": is_set, # an override row exists
|
|
|
|
|
}
|
|
|
|
|
if s.secret:
|
|
|
|
|
item["default_is_set"] = bool(_default(s))
|
|
|
|
|
else:
|
|
|
|
|
item["value"] = get(db, s.key)
|
|
|
|
|
item["default"] = _default(s)
|
|
|
|
|
groups.setdefault(s.group, []).append(item)
|
|
|
|
|
return {"groups": groups, "secrets_manageable": secrets_manageable()}
|