erster docker release

This commit is contained in:
Patrick Asmus
2026-09-15 18:10:57 +02:00
parent 794156a088
commit 9fa589789e
21 changed files with 3182 additions and 71 deletions
+369
View File
@@ -0,0 +1,369 @@
import base64
import contextlib
import datetime
import os
import secrets
import sys
from fastapi import FastAPI, HTTPException, Request, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from typing import Optional
from config import Config
from models import init_db, get_session, Job, Recording as RecordingModel, LogEntry
from recorder import RecordingManager, format_elapsed
from scheduler import StreamScheduler
DATA_DIR = os.environ.get("DATA_DIR", "/app/data")
STATIC_DIR = os.environ.get("STATIC_DIR", "/app/frontend/static")
os.makedirs(DATA_DIR, exist_ok=True)
config = Config(os.path.join(DATA_DIR, "config.yml"))
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.start()
yield
scheduler.stop()
app = FastAPI(title="stream-recorder", version="3.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
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.startswith("/api/health"):
return await call_next(request)
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Basic "):
try:
decoded = base64.b64decode(auth_header[6:]).decode("utf-8")
username, password = decoded.split(":", 1)
expected_user = config.get("auth", "username", default="admin")
expected_pass = config.get("auth", "password", default="stream-recorder")
if secrets.compare_digest(username, expected_user) and secrets.compare_digest(password, expected_pass):
return await call_next(request)
except Exception:
pass
return JSONResponse(
status_code=401,
content={"detail": "Unauthorized"},
headers={"WWW-Authenticate": 'Basic realm="stream-recorder"'},
)
# --- Health ---
@app.get("/api/health")
async def health():
return {"status": "ok", "version": "3.0.0"}
# --- Jobs CRUD ---
class JobCreate(BaseModel):
name: str
stream_url: str
stream_type: str = "auto"
output_format: str = ""
max_duration: Optional[int] = None
extra_ffmpeg_args: str = ""
segment_duration: Optional[int] = None
schedule_enabled: bool = False
schedule_once: bool = False
schedule_date: str = ""
schedule_days: str = "*"
schedule_start: str = ""
schedule_stop: str = ""
ntfy_enabled: bool = False
plik_enabled: bool = False
delete_after_upload: bool = False
metadata_monitor_enabled: bool = False
metadata_pattern: str = ""
metadata_grace_period: int = 300
metadata_poll_interval: int = 30
class JobUpdate(JobCreate):
pass
@app.get("/api/jobs")
async def list_jobs():
session = get_session()
try:
jobs = session.query(Job).order_by(Job.id).all()
result = []
for j in jobs:
is_recording = session.query(
session.query(RecordingModel).filter_by(job_id=j.id, status="running").exists()
).scalar()
result.append({
"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,
"ntfy_enabled": j.ntfy_enabled,
"plik_enabled": j.plik_enabled,
"delete_after_upload": j.delete_after_upload,
"metadata_monitor_enabled": j.metadata_monitor_enabled,
"metadata_pattern": j.metadata_pattern,
"metadata_grace_period": j.metadata_grace_period,
"metadata_poll_interval": j.metadata_poll_interval,
"is_recording": is_recording,
"created_at": j.created_at.isoformat() if j.created_at else None,
})
return result
finally:
session.close()
@app.post("/api/jobs")
async def create_job(data: JobCreate):
session = get_session()
try:
job = Job(**data.model_dump())
session.add(job)
session.commit()
return {"id": job.id, "message": "Job erstellt"}
finally:
session.close()
@app.get("/api/jobs/{job_id}")
async 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 {
"id": job.id, "name": job.name, "stream_url": job.stream_url,
"stream_type": job.stream_type, "output_format": job.output_format,
"max_duration": job.max_duration, "extra_ffmpeg_args": job.extra_ffmpeg_args,
"segment_duration": job.segment_duration,
"schedule_enabled": job.schedule_enabled, "schedule_once": job.schedule_once,
"schedule_date": job.schedule_date, "schedule_days": job.schedule_days,
"schedule_start": job.schedule_start, "schedule_stop": job.schedule_stop,
"ntfy_enabled": job.ntfy_enabled,
"plik_enabled": job.plik_enabled, "delete_after_upload": job.delete_after_upload,
"metadata_monitor_enabled": job.metadata_monitor_enabled,
"metadata_pattern": job.metadata_pattern,
"metadata_grace_period": job.metadata_grace_period,
"metadata_poll_interval": job.metadata_poll_interval,
}
finally:
session.close()
@app.put("/api/jobs/{job_id}")
async 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")
for k, v in data.model_dump().items():
setattr(job, k, v)
job.updated_at = datetime.datetime.utcnow()
session.commit()
return {"message": "Job aktualisiert"}
finally:
session.close()
@app.delete("/api/jobs/{job_id}")
async 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")
session.delete(job)
session.commit()
return {"message": "Job gelöscht"}
finally:
session.close()
@app.post("/api/jobs/{job_id}/record")
async def start_job_recording(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")
job_data = {
"name": job.name, "stream_url": job.stream_url,
"stream_type": job.stream_type, "output_format": job.output_format,
"max_duration": job.max_duration, "extra_ffmpeg_args": job.extra_ffmpeg_args,
"segment_duration": job.segment_duration,
"metadata_monitor_enabled": job.metadata_monitor_enabled,
"metadata_pattern": job.metadata_pattern,
"metadata_grace_period": job.metadata_grace_period or 300,
"metadata_poll_interval": job.metadata_poll_interval or 30,
"ntfy_enabled": job.ntfy_enabled,
"plik_enabled": job.plik_enabled,
"delete_after_upload": job.delete_after_upload,
}
finally:
session.close()
result = recorder.start_recording(job_id=job_id, **job_data)
return result
# --- Quick Recording ---
class QuickRecordRequest(BaseModel):
url: str
name: str = ""
stream_type: str = "auto"
max_duration: Optional[int] = None
@app.post("/api/quick-record")
async def quick_record(data: QuickRecordRequest):
name = data.name or f"Schnellaufnahme_{datetime.datetime.now().strftime('%H%M%S')}"
result = recorder.start_recording(
job_id=None,
name=name,
stream_url=data.url,
stream_type=data.stream_type,
max_duration=data.max_duration,
)
return result
# --- Recordings ---
@app.get("/api/recordings")
async def list_recordings(active_only: bool = False, limit: int = 50):
if active_only:
return recorder.get_all_active()
return recorder.get_all_recordings(limit=limit)
@app.get("/api/recordings/{rec_id}")
async 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")
async def stop_recording(rec_id: int):
recorder.stop_recording(rec_id)
return {"message": "Aufnahme gestoppt"}
@app.post("/api/recordings/{rec_id}/extend")
async def extend_recording(rec_id: int, minutes: int = Query(default=30)):
ok = recorder.extend_recording(rec_id, minutes * 60)
if not 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")
async 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")
async def get_logs(limit: int = 100, level: str = ""):
session = get_session()
try:
q = session.query(LogEntry).order_by(LogEntry.id.desc())
if level:
q = q.filter(LogEntry.level == level.upper())
logs = q.limit(limit).all()
return [
{
"id": l.id,
"timestamp": l.timestamp.isoformat() if l.timestamp else None,
"level": l.level,
"message": l.message,
"job_name": l.job_name,
}
for l in logs
]
finally:
session.close()
# --- Config ---
@app.get("/api/config")
async def get_config():
safe_config = {**config.data}
if "auth" in safe_config:
safe_auth = {**safe_config["auth"]}
if safe_auth.get("password"):
safe_auth["password"] = "***"
safe_config["auth"] = safe_auth
return safe_config
class ConfigUpdate(BaseModel):
data: dict
@app.put("/api/config")
async def update_config(payload: ConfigUpdate):
from config import _deep_merge
_deep_merge(config.data, payload.data)
config.save()
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)