Files

910 lines
29 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import base64
import binascii
import contextlib
import datetime
import hashlib
import hmac
import logging
import os
import re
import sys
import threading
import time
from typing import Annotated, Literal, Optional
from urllib.parse import urlsplit
from fastapi import FastAPI, HTTPException, Request, Query
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, field_validator
from starlette.concurrency import run_in_threadpool
from starlette.middleware.base import BaseHTTPMiddleware
from config import Config, hash_password, verify_password, validate_download_path
from models import (init_db, get_session, iso_utc, utcnow,
Job, Station, Recording as RecordingModel, LogEntry)
from recorder import RecordingManager, validate_ffmpeg_args, validate_stream_url
from scheduler import StreamScheduler, compute_duration, validate_schedule
APP_VERSION = os.environ.get("APP_VERSION", "dev")
DATA_DIR = os.environ.get("DATA_DIR", "/app/data")
STATIC_DIR = os.environ.get("STATIC_DIR", "/app/frontend/static")
ENABLE_API_DOCS = os.environ.get("ENABLE_API_DOCS", "") == "1"
LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s"
LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR")
os.makedirs(DATA_DIR, exist_ok=True)
config = Config(os.path.join(DATA_DIR, "config.yml"))
def _apply_log_level(level: str):
level = str(level or "INFO").upper()
logging.getLogger().setLevel(getattr(logging, level, logging.INFO) if level in LOG_LEVELS else logging.INFO)
logging.basicConfig(format=LOG_FORMAT, stream=sys.stdout, force=True)
_apply_log_level(config.get("logging", "level", default="INFO"))
logging.getLogger("apscheduler").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
logger = logging.getLogger("app")
if config.password_migrated:
logger.warning("Klartext-Passwort in config.yml wurde in einen Hash migriert")
if config.default_password_active:
logger.warning("Basic-Auth nutzt das bekannte Standardpasswort – bitte in den Einstellungen ändern!")
if config.generated_password:
logger.warning("=" * 60)
logger.warning("Erster Start: Basic-Auth wurde aktiviert.")
logger.warning("Benutzer: %s", config.get("auth", "username", default="admin"))
logger.warning("Passwort: %s", config.generated_password)
logger.warning("Bitte nach dem ersten Login in den Einstellungen ändern.")
logger.warning("=" * 60)
config.generated_password = None
init_db(os.path.join(DATA_DIR, "stream-recorder.db"))
recorder = RecordingManager(config)
scheduler = StreamScheduler(recorder)
@contextlib.asynccontextmanager
async def lifespan(app):
recorder.cleanup_stale()
scheduler.prune_logs()
scheduler.start()
yield
scheduler.stop()
app = FastAPI(
title="stream-recorder",
version=APP_VERSION,
lifespan=lifespan,
docs_url="/docs" if ENABLE_API_DOCS else None,
redoc_url="/redoc" if ENABLE_API_DOCS else None,
openapi_url="/openapi.json" if ENABLE_API_DOCS else None,
)
# --- Auth ---
AUTH_CACHE_MAX = 256
AUTH_FAIL_LIMIT = 10
AUTH_FAIL_WINDOW = 300
EXEMPT_PATHS = {"/api/health"}
_auth_lock = threading.Lock()
_auth_cache: dict[str, str] = {}
_auth_failures: dict[str, list[float]] = {}
def clear_auth_cache():
with _auth_lock:
_auth_cache.clear()
def reset_auth_failures():
with _auth_lock:
_auth_failures.clear()
def _recent_failures(ip: str, now: float) -> list[float]:
fails = [t for t in _auth_failures.get(ip, []) if now - t < AUTH_FAIL_WINDOW]
if fails:
_auth_failures[ip] = fails
else:
_auth_failures.pop(ip, None)
return fails
def _register_failure(ip: str, now: float):
with _auth_lock:
if len(_auth_failures) > 10000:
for key in list(_auth_failures):
_recent_failures(key, now)
_auth_failures.setdefault(ip, []).append(now)
def _check_credentials(auth_header: str) -> bool:
expected_user = config.get("auth", "username", default="admin")
pw_hash = config.get("auth", "password_hash", default="")
if not pw_hash:
return False
cache_key = hashlib.sha256(auth_header.encode("utf-8", "surrogateescape")).hexdigest()
marker = f"{expected_user}\x00{pw_hash}"
with _auth_lock:
if _auth_cache.get(cache_key) == marker:
return True
if not auth_header.startswith("Basic "):
return False
try:
decoded = base64.b64decode(auth_header[6:], validate=True).decode("utf-8")
username, password = decoded.split(":", 1)
except (binascii.Error, UnicodeDecodeError, ValueError):
return False
user_ok = hmac.compare_digest(username.encode("utf-8"), str(expected_user).encode("utf-8"))
pass_ok = verify_password(password, pw_hash)
if not (user_ok and pass_ok):
return False
with _auth_lock:
if len(_auth_cache) >= AUTH_CACHE_MAX:
_auth_cache.pop(next(iter(_auth_cache)))
_auth_cache[cache_key] = marker
return True
def _unauthorized():
return JSONResponse(
status_code=401,
content={"detail": "Unauthorized"},
headers={"WWW-Authenticate": 'Basic realm="stream-recorder"'},
)
async def auth_middleware(request: Request, call_next):
if not config.get("auth", "enabled", default=False):
return await call_next(request)
if request.url.path in EXEMPT_PATHS:
return await call_next(request)
ip = request.client.host if request.client else "unknown"
now = time.monotonic()
with _auth_lock:
blocked = len(_recent_failures(ip, now)) >= AUTH_FAIL_LIMIT
if blocked:
return JSONResponse(status_code=429, content={"detail": "Zu viele fehlgeschlagene Anmeldeversuche"})
auth_header = request.headers.get("Authorization", "")
if not auth_header:
return _unauthorized()
try:
ok = await run_in_threadpool(_check_credentials, auth_header)
except Exception:
ok = False
if ok:
return await call_next(request)
_register_failure(ip, now)
return _unauthorized()
# --- CSRF ---
_DEFAULT_PORTS = {"http": "80", "https": "443"}
def _norm_host(netloc: str, scheme: str) -> str:
netloc = netloc.strip().lower()
if not netloc:
return ""
if netloc.startswith("["):
host, _, rest = netloc.partition("]")
host += "]"
port = rest[1:] if rest.startswith(":") else ""
elif ":" in netloc:
host, port = netloc.rsplit(":", 1)
else:
host, port = netloc, ""
if not port:
port = _DEFAULT_PORTS.get(scheme, "")
return f"{host}:{port}"
async def csrf_middleware(request: Request, call_next):
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
if request.headers.get("sec-fetch-site", "").lower() == "cross-site":
return JSONResponse(status_code=403, content={"detail": "Cross-Site-Anfrage abgelehnt"})
origin = request.headers.get("origin")
if origin is not None:
try:
o = urlsplit(origin)
origin_host = _norm_host(o.netloc, o.scheme.lower())
except ValueError:
origin_host = ""
candidates = set()
for header in ("host", "x-forwarded-host"):
value = request.headers.get(header)
if value:
candidates.add(_norm_host(value.split(",")[0], o.scheme.lower() if origin_host else "http"))
if not origin_host or origin_host not in candidates:
return JSONResponse(status_code=403, content={"detail": "Ungültiger Origin"})
return await call_next(request)
# --- Security-Header ---
SECURITY_HEADERS = {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "no-referrer",
"Content-Security-Policy": (
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; "
"form-action 'self'; object-src 'none'"
),
}
async def security_headers_middleware(request: Request, call_next):
response = await call_next(request)
for k, v in SECURITY_HEADERS.items():
response.headers[k] = v
return response
# Zuletzt hinzugefügt = äußerste Schicht
app.add_middleware(BaseHTTPMiddleware, dispatch=auth_middleware)
app.add_middleware(BaseHTTPMiddleware, dispatch=csrf_middleware)
app.add_middleware(BaseHTTPMiddleware, dispatch=security_headers_middleware)
# --- Health ---
@app.get("/api/health")
async def health():
return {"status": "ok", "version": APP_VERSION}
# --- Validierung ---
StreamType = Literal["auto", "http", "hls", "mp3", "aac", "ogg", "flac", "rtmp"]
OutputFormat = Literal["", "auto", "mp3", "aac", "m4a", "mp4", "mkv", "ogg", "opus", "flac", "ts", "wav"]
Name = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=200)]
Description = Annotated[str, StringConstraints(max_length=2000)]
MaxDuration = Optional[Annotated[int, Field(ge=1, le=2592000)]]
Buffer = Annotated[int, Field(ge=0, le=120)]
def _bad_request(e: Exception):
raise HTTPException(400, str(e))
def _check_url(url: str) -> str:
try:
return validate_stream_url(url)
except ValueError as e:
_bad_request(e)
class StationCreate(BaseModel):
name: Name
stream_url: Annotated[str, StringConstraints(strip_whitespace=True)]
stream_type: StreamType = "auto"
description: Description = ""
class StationUpdate(StationCreate):
pass
def _station_to_dict(station: Station) -> dict:
return {
"id": station.id,
"name": station.name,
"stream_url": station.stream_url,
"stream_type": station.stream_type,
"description": station.description,
"created_at": iso_utc(station.created_at),
"updated_at": iso_utc(station.updated_at),
}
def _clean_station_data(data: StationCreate) -> dict:
values = data.model_dump()
values["stream_url"] = _check_url(values["stream_url"])
values["description"] = (values.get("description") or "").strip()
return values
# --- Stations ---
@app.get("/api/stations")
def list_stations():
session = get_session()
try:
stations = session.query(Station).order_by(Station.name.collate("NOCASE"), Station.id).all()
return [_station_to_dict(s) for s in stations]
finally:
session.close()
@app.post("/api/stations")
def create_station(data: StationCreate):
values = _clean_station_data(data)
session = get_session()
try:
station = Station(**values)
session.add(station)
session.commit()
return {"id": station.id, "message": "Sender gespeichert"}
finally:
session.close()
@app.get("/api/stations/{station_id}")
def get_station(station_id: int):
session = get_session()
try:
station = session.query(Station).filter_by(id=station_id).first()
if not station:
raise HTTPException(404, "Sender nicht gefunden")
return _station_to_dict(station)
finally:
session.close()
@app.put("/api/stations/{station_id}")
def update_station(station_id: int, data: StationUpdate):
values = _clean_station_data(data)
session = get_session()
try:
station = session.query(Station).filter_by(id=station_id).first()
if not station:
raise HTTPException(404, "Sender nicht gefunden")
for k, v in values.items():
setattr(station, k, v)
station.updated_at = utcnow()
session.commit()
return {"message": "Sender aktualisiert"}
finally:
session.close()
@app.delete("/api/stations/{station_id}")
def delete_station(station_id: int):
session = get_session()
try:
station = session.query(Station).filter_by(id=station_id).first()
if not station:
raise HTTPException(404, "Sender nicht gefunden")
session.query(Job).filter(Job.station_id == station_id).update(
{Job.station_id: None}, synchronize_session=False)
session.delete(station)
session.commit()
return {"message": "Sender gelöscht"}
finally:
session.close()
# --- Jobs ---
class JobCreate(BaseModel):
name: Name
stream_url: Annotated[str, StringConstraints(strip_whitespace=True)]
stream_type: StreamType = "auto"
output_format: OutputFormat = ""
max_duration: MaxDuration = None
extra_ffmpeg_args: Annotated[str, StringConstraints(strip_whitespace=True)] = ""
segment_duration: Optional[int] = None
schedule_enabled: bool = False
schedule_once: bool = False
schedule_date: Annotated[str, StringConstraints(strip_whitespace=True, max_length=10)] = ""
schedule_days: Annotated[str, StringConstraints(strip_whitespace=True, max_length=50)] = "*"
schedule_start: Annotated[str, StringConstraints(strip_whitespace=True, max_length=5)] = ""
schedule_stop: Annotated[str, StringConstraints(strip_whitespace=True, max_length=5)] = ""
schedule_end_date: Annotated[str, StringConstraints(strip_whitespace=True, max_length=10)] = ""
pre_buffer: Buffer = 0
post_buffer: Buffer = 0
description: Description = ""
ntfy_enabled: bool = False
plik_enabled: bool = False
delete_after_upload: bool = False
station_id: Optional[int] = None
@field_validator("segment_duration")
@classmethod
def _segment_duration(cls, v):
if not v:
return None
if v < 10 or v > 86400:
raise ValueError("Segmentdauer muss zwischen 10 und 86400 Sekunden liegen")
return v
@field_validator("schedule_days")
@classmethod
def _schedule_days(cls, v):
return v or "*"
class JobUpdate(JobCreate):
pass
def _validate_job(data: JobCreate, session) -> dict:
values = data.model_dump()
values["stream_url"] = _check_url(values["stream_url"])
try:
validate_ffmpeg_args(values["extra_ffmpeg_args"])
validate_schedule(values)
except ValueError as e:
_bad_request(e)
if values["station_id"] is not None:
if not session.query(Station).filter_by(id=values["station_id"]).first():
raise HTTPException(400, "Sender nicht gefunden")
return values
def _job_active(session, job_id: int) -> bool:
return session.query(
session.query(RecordingModel).filter(
RecordingModel.job_id == job_id,
RecordingModel.status.in_(["running", "starting"]),
).exists()
).scalar()
def _job_to_dict(j: Job) -> dict:
return {
"id": j.id,
"name": j.name,
"stream_url": j.stream_url,
"stream_type": j.stream_type,
"output_format": j.output_format,
"max_duration": j.max_duration,
"extra_ffmpeg_args": j.extra_ffmpeg_args,
"segment_duration": j.segment_duration,
"schedule_enabled": j.schedule_enabled,
"schedule_once": j.schedule_once,
"schedule_date": j.schedule_date,
"schedule_days": j.schedule_days,
"schedule_start": j.schedule_start,
"schedule_stop": j.schedule_stop,
"schedule_end_date": j.schedule_end_date,
"pre_buffer": j.pre_buffer or 0,
"post_buffer": j.post_buffer or 0,
"description": j.description,
"ntfy_enabled": j.ntfy_enabled,
"plik_enabled": j.plik_enabled,
"delete_after_upload": j.delete_after_upload,
"station_id": j.station_id,
"created_at": iso_utc(j.created_at),
"updated_at": iso_utc(j.updated_at),
}
@app.get("/api/jobs")
def list_jobs():
session = get_session()
try:
jobs = session.query(Job).order_by(Job.id).all()
station_ids = {j.station_id for j in jobs if j.station_id}
station_names = {}
if station_ids:
for s in session.query(Station).filter(Station.id.in_(station_ids)).all():
station_names[s.id] = s.name
active_job_ids = {
r[0] for r in session.query(RecordingModel.job_id).filter(
RecordingModel.job_id.isnot(None),
RecordingModel.status.in_(["running", "starting"]),
).all()
}
result = []
for j in jobs:
d = _job_to_dict(j)
d["station_name"] = station_names.get(j.station_id) if j.station_id else None
d["is_recording"] = j.id in active_job_ids
result.append(d)
return result
finally:
session.close()
@app.post("/api/jobs")
def create_job(data: JobCreate):
session = get_session()
try:
values = _validate_job(data, session)
job = Job(**values)
session.add(job)
session.commit()
return {"id": job.id, "message": "Job erstellt"}
finally:
session.close()
@app.get("/api/jobs/{job_id}")
def get_job(job_id: int):
session = get_session()
try:
job = session.query(Job).filter_by(id=job_id).first()
if not job:
raise HTTPException(404, "Job nicht gefunden")
return _job_to_dict(job)
finally:
session.close()
@app.put("/api/jobs/{job_id}")
def update_job(job_id: int, data: JobUpdate):
session = get_session()
try:
job = session.query(Job).filter_by(id=job_id).first()
if not job:
raise HTTPException(404, "Job nicht gefunden")
values = _validate_job(data, session)
for k, v in values.items():
setattr(job, k, v)
job.updated_at = utcnow()
session.commit()
return {"message": "Job aktualisiert"}
finally:
session.close()
@app.delete("/api/jobs/{job_id}")
def delete_job(job_id: int):
session = get_session()
try:
job = session.query(Job).filter_by(id=job_id).first()
if not job:
raise HTTPException(404, "Job nicht gefunden")
if _job_active(session, job_id):
raise HTTPException(400, "Job nimmt gerade auf – bitte zuerst stoppen")
session.delete(job)
session.commit()
return {"message": "Job gelöscht"}
finally:
session.close()
_job_start_lock = threading.Lock()
@app.post("/api/jobs/{job_id}/record")
def start_job_recording(job_id: int):
with _job_start_lock:
session = get_session()
try:
job = session.query(Job).filter_by(id=job_id).first()
if not job:
raise HTTPException(404, "Job nicht gefunden")
if _job_active(session, job_id):
raise HTTPException(409, "Job nimmt bereits auf")
job_dict = _job_to_dict(job)
finally:
session.close()
try:
duration = compute_duration(job_dict, apply_buffers=False)
except (ValueError, TypeError) as e:
raise HTTPException(400, f"Ungültige Zeitplandaten: {e}")
try:
return recorder.start_recording(
job_id=job_id,
name=job_dict["name"],
stream_url=job_dict["stream_url"],
stream_type=job_dict["stream_type"],
output_format=job_dict["output_format"] or "",
max_duration=duration,
extra_ffmpeg_args=job_dict["extra_ffmpeg_args"] or "",
segment_duration=job_dict["segment_duration"],
description=job_dict["description"] or "",
ntfy_enabled=job_dict["ntfy_enabled"],
plik_enabled=job_dict["plik_enabled"],
delete_after_upload=job_dict["delete_after_upload"],
)
except ValueError as e:
_bad_request(e)
# --- Quick Recording ---
class QuickRecordRequest(BaseModel):
url: Annotated[str, StringConstraints(strip_whitespace=True)]
name: Annotated[str, StringConstraints(strip_whitespace=True, max_length=200)] = ""
stream_type: StreamType = "auto"
max_duration: MaxDuration = None
@app.post("/api/quick-record")
def quick_record(data: QuickRecordRequest):
url = _check_url(data.url)
name = data.name or f"Schnellaufnahme_{datetime.datetime.now().strftime('%H%M%S')}"
try:
return recorder.start_recording(
job_id=None,
name=name,
stream_url=url,
stream_type=data.stream_type,
max_duration=data.max_duration,
)
except ValueError as e:
_bad_request(e)
# --- Recordings ---
@app.get("/api/recordings")
def list_recordings(active_only: bool = False, limit: int = Query(default=50, ge=1, le=500)):
if active_only:
return recorder.get_all_active()
return recorder.get_all_recordings(limit=limit)
@app.get("/api/recordings/{rec_id}")
def get_recording(rec_id: int):
status = recorder.get_status(rec_id)
if not status:
raise HTTPException(404, "Aufnahme nicht gefunden")
return status
@app.post("/api/recordings/{rec_id}/stop")
def stop_recording(rec_id: int):
status = recorder.get_status(rec_id)
if not status:
raise HTTPException(404, "Aufnahme nicht gefunden")
if status.get("status") not in ("running", "starting"):
raise HTTPException(400, "Aufnahme läuft nicht")
if status.get("job_id"):
scheduler.suppress_current_occurrence(status["job_id"])
recorder.stop_recording(rec_id)
return {"message": "Aufnahme gestoppt"}
@app.post("/api/recordings/{rec_id}/extend")
def extend_recording(rec_id: int, minutes: int = Query(default=30, ge=1, le=1440)):
result = recorder.extend_recording(rec_id, minutes * 60)
if result == "not_found":
raise HTTPException(404, "Aufnahme nicht gefunden")
if result == "unlimited":
raise HTTPException(400, "Aufnahme ist bereits unbegrenzt")
if result != "ok":
raise HTTPException(400, "Aufnahme kann nicht verlängert werden")
return {"message": f"Aufnahme um {minutes} Minuten verlängert"}
@app.post("/api/recordings/{rec_id}/unlimited")
def remove_limit(rec_id: int):
ok = recorder.remove_duration_limit(rec_id)
if not ok:
raise HTTPException(400, "Zeitlimit kann nicht entfernt werden")
return {"message": "Zeitlimit entfernt"}
# --- Logs ---
@app.get("/api/logs")
def get_logs(
limit: int = Query(default=100, ge=1, le=1000),
level: str = "",
):
level = level.strip().upper()
if level not in ("", "DEBUG", "INFO", "WARN", "WARNING", "ERROR"):
raise HTTPException(400, "Ungültiges Log-Level")
session = get_session()
try:
q = session.query(LogEntry).order_by(LogEntry.id.desc())
if level in ("WARN", "WARNING"):
q = q.filter(LogEntry.level.in_(["WARN", "WARNING"]))
elif level:
q = q.filter(LogEntry.level == level)
logs = q.limit(limit).all()
return [
{
"id": entry.id,
"timestamp": iso_utc(entry.timestamp),
"level": entry.level,
"message": entry.message,
"job_name": entry.job_name,
}
for entry in logs
]
finally:
session.close()
# --- Config ---
class _StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class RecordingConfigIn(_StrictModel):
download_path: Optional[str] = None
max_retries: Optional[Annotated[int, Field(ge=0, le=1000)]] = None
retry_delay: Optional[Annotated[int, Field(ge=1, le=3600)]] = None
class AuthConfigIn(_StrictModel):
enabled: Optional[bool] = None
username: Optional[Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=64)]] = None
password: Optional[str] = None
class NtfyConfigIn(_StrictModel):
url: Optional[Annotated[str, StringConstraints(strip_whitespace=True, max_length=1000)]] = None
events: Optional[Annotated[str, StringConstraints(strip_whitespace=True, max_length=100)]] = None
token: Optional[Annotated[str, StringConstraints(max_length=500)]] = None
class PlikConfigIn(_StrictModel):
url: Optional[Annotated[str, StringConstraints(strip_whitespace=True, max_length=1000)]] = None
ttl: Optional[Annotated[str, StringConstraints(strip_whitespace=True, max_length=20)]] = None
api_key: Optional[Annotated[str, StringConstraints(max_length=500)]] = None
class ScheduleConfigIn(_StrictModel):
default_pre_buffer: Optional[Buffer] = None
default_post_buffer: Optional[Buffer] = None
class LoggingConfigIn(_StrictModel):
level: Optional[Literal["DEBUG", "INFO", "WARNING", "ERROR"]] = None
class ConfigDataIn(_StrictModel):
recording: Optional[RecordingConfigIn] = None
auth: Optional[AuthConfigIn] = None
ntfy: Optional[NtfyConfigIn] = None
plik: Optional[PlikConfigIn] = None
schedule: Optional[ScheduleConfigIn] = None
logging: Optional[LoggingConfigIn] = None
class ConfigUpdate(_StrictModel):
data: ConfigDataIn
NTFY_EVENTS = {"start", "stop", "error", "upload"}
def _check_http_url(url: str, label: str):
if not url:
return
try:
parts = urlsplit(url)
hostname = parts.hostname
except ValueError:
hostname = None
parts = None
if not parts or parts.scheme not in ("http", "https") or not hostname or any(c.isspace() for c in url):
raise HTTPException(400, f"{label}-URL muss eine gültige http(s)-URL sein")
@app.get("/api/config")
def get_config():
with config.lock:
g = config.get
return {
"recording": {
"download_path": g("recording", "download_path"),
"max_retries": g("recording", "max_retries"),
"retry_delay": g("recording", "retry_delay"),
},
"server": {
"host": g("server", "host"),
"port": g("server", "port"),
},
"auth": {
"enabled": bool(g("auth", "enabled", default=False)),
"username": g("auth", "username", default="admin"),
"password_set": bool(g("auth", "password_hash", default="")),
},
"ntfy": {
"url": g("ntfy", "url", default=""),
"events": g("ntfy", "events", default=""),
"token_set": bool(g("ntfy", "token", default="")),
},
"plik": {
"url": g("plik", "url", default=""),
"ttl": g("plik", "ttl", default=""),
"api_key_set": bool(g("plik", "api_key", default="")),
},
"schedule": {
"default_pre_buffer": g("schedule", "default_pre_buffer"),
"default_post_buffer": g("schedule", "default_post_buffer"),
},
"logging": {
"level": g("logging", "level", default="INFO"),
},
}
@app.put("/api/config")
def update_config(payload: ConfigUpdate):
sections = {
name: {k: v for k, v in section.model_dump(exclude_unset=True).items() if v is not None}
for name, section in payload.data
if section is not None
}
rec = sections.get("recording", {})
if "download_path" in rec:
try:
rec["download_path"] = validate_download_path(rec["download_path"])
except ValueError as e:
_bad_request(e)
ntfy = sections.get("ntfy", {})
if "url" in ntfy:
_check_http_url(ntfy["url"], "NTFY")
if "events" in ntfy:
events = [e.strip() for e in ntfy["events"].split(",") if e.strip()]
invalid = [e for e in events if e not in NTFY_EVENTS]
if invalid:
raise HTTPException(400, f"Unbekannte NTFY-Ereignisse: {', '.join(invalid)}")
ntfy["events"] = ",".join(events)
plik = sections.get("plik", {})
if "url" in plik:
_check_http_url(plik["url"], "Plik")
if "ttl" in plik and plik["ttl"] and not re.fullmatch(r"\d+[dh]?", plik["ttl"]):
raise HTTPException(400, "Plik-TTL muss z.B. 30d, 12h oder eine Sekundenzahl sein")
auth = sections.get("auth", {})
new_password = auth.pop("password", None)
if new_password is not None:
if new_password == "":
raise HTTPException(400, "Passwort darf nicht leer sein")
if len(new_password) < 8:
raise HTTPException(400, "Passwort muss mindestens 8 Zeichen lang sein")
if len(new_password) > 1024:
raise HTTPException(400, "Passwort ist zu lang")
with config.lock:
will_enable = auth.get("enabled", config.get("auth", "enabled", default=False))
has_password = bool(new_password) or bool(config.get("auth", "password_hash", default=""))
if will_enable and not has_password:
raise HTTPException(400, "Zum Aktivieren der Anmeldung muss ein Passwort gesetzt sein")
auth_changed = new_password is not None or "username" in auth
if new_password is not None:
auth["password_hash"] = hash_password(new_password)
for name, values in sections.items():
config.data.setdefault(name, {}).update(values)
config.save()
if auth_changed:
clear_auth_cache()
if "level" in sections.get("logging", {}):
_apply_log_level(sections["logging"]["level"])
return {"message": "Konfiguration gespeichert"}
# --- Static files (Frontend) ---
if os.path.isdir(STATIC_DIR):
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
if __name__ == "__main__":
import uvicorn
host = config.get("server", "host", default="0.0.0.0")
port = config.get("server", "port", default=8484)
uvicorn.run("app:app", host=host, port=port, reload=False)