Dateigröße pro Aufnahme entkoppelt - feste Größe nach Abschluss
Build Dev Docker Image / build-amd64 (push) Successful in 10s
Build Dev Docker Image / build-arm64 (push) Successful in 16s
Build Dev Docker Image / publish-dev-manifest (push) Successful in 9s

Die Dateigröße wird jetzt pro Aufnahme korrekt berechnet statt das
gesamte Verzeichnis zu summieren. Bei abgeschlossenen Aufnahmen wird
die Größe einmalig gespeichert und nicht mehr durch laufende Aufnahmen
verändert.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Asmus
2026-09-16 11:46:18 +02:00
co-authored by Claude Opus 4.6
parent 1ad87c7186
commit 851ff086ac
6 changed files with 600 additions and 124 deletions
+104 -1
View File
@@ -13,7 +13,7 @@ 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 models import init_db, get_session, Job, Station, Recording as RecordingModel, LogEntry
from recorder import RecordingManager, format_elapsed
from scheduler import StreamScheduler
@@ -83,6 +83,106 @@ async def health():
# --- Jobs CRUD ---
class StationCreate(BaseModel):
name: str
stream_url: str
stream_type: str = "auto"
description: str = ""
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": station.created_at.isoformat() if station.created_at else None,
"updated_at": station.updated_at.isoformat() if station.updated_at else None,
}
def _clean_station_data(data: StationCreate | StationUpdate) -> dict:
values = data.model_dump()
values["name"] = values["name"].strip()
values["stream_url"] = values["stream_url"].strip()
values["stream_type"] = (values.get("stream_type") or "auto").strip() or "auto"
values["description"] = (values.get("description") or "").strip()
if not values["name"] or not values["stream_url"]:
raise HTTPException(400, "Name und URL sind Pflichtfelder")
return values
@app.get("/api/stations")
async 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")
async 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}")
async 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}")
async 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 = datetime.datetime.utcnow()
session.commit()
return {"message": "Sender aktualisiert"}
finally:
session.close()
@app.delete("/api/stations/{station_id}")
async 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.delete(station)
session.commit()
return {"message": "Sender gelöscht"}
finally:
session.close()
class JobCreate(BaseModel):
name: str
stream_url: str
@@ -310,6 +410,9 @@ async def get_recording(rec_id: int):
@app.post("/api/recordings/{rec_id}/stop")
async def stop_recording(rec_id: int):
status = recorder.get_status(rec_id)
if status and status.get("job_id"):
scheduler.suppress_current_occurrence(status["job_id"])
recorder.stop_recording(rec_id)
return {"message": "Aufnahme gestoppt"}
+24 -1
View File
@@ -1,5 +1,5 @@
import datetime
from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Text
from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Text, text
from sqlalchemy.orm import declarative_base, sessionmaker
Base = declarative_base()
@@ -41,6 +41,18 @@ class Job(Base):
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
class Station(Base):
__tablename__ = "stations"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(200), nullable=False)
stream_url = Column(String(1000), nullable=False)
stream_type = Column(String(20), default="auto")
description = Column(Text, default="")
created_at = Column(DateTime, default=datetime.datetime.utcnow)
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
class Recording(Base):
__tablename__ = "recordings"
@@ -59,6 +71,7 @@ class Recording(Base):
segment_mode = Column(Boolean, default=False)
is_scheduled = Column(Boolean, default=False)
error_message = Column(Text, nullable=True)
file_size = Column(Integer, nullable=True)
class LogEntry(Base):
@@ -80,6 +93,16 @@ def init_db(db_path: str):
_engine = create_engine(f"sqlite:///{db_path}", connect_args={"check_same_thread": False})
Base.metadata.create_all(_engine)
_SessionLocal = sessionmaker(bind=_engine)
_migrate(_engine)
def _migrate(engine):
with engine.connect() as conn:
try:
conn.execute(text("ALTER TABLE recordings ADD COLUMN file_size INTEGER"))
conn.commit()
except Exception:
pass
def get_session():
+119 -80
View File
@@ -1,5 +1,6 @@
import asyncio
import datetime
import glob as globmod
import os
import re
import signal
@@ -68,6 +69,7 @@ class RecordingManager:
self._ntfy = None
self._plik = None
self._rec_options: dict[int, dict] = {}
self._output_files: dict[int, list[str]] = {}
def _get_metadata_monitor(self):
if self._metadata_monitor is None:
@@ -173,6 +175,15 @@ class RecordingManager:
finally:
session.close()
self._rec_options[rec_id] = {
"ntfy_enabled": ntfy_enabled,
"plik_enabled": plik_enabled,
"delete_after_upload": delete_after_upload,
"segment_mode": segment_mode,
"description": description,
}
self._output_files[rec_id] = [output_file]
stop_event = threading.Event()
self._stop_events[rec_id] = stop_event
@@ -185,14 +196,6 @@ class RecordingManager:
self._threads[rec_id] = thread
thread.start()
self._rec_options[rec_id] = {
"ntfy_enabled": ntfy_enabled,
"plik_enabled": plik_enabled,
"delete_after_upload": delete_after_upload,
"segment_mode": segment_mode,
"description": description,
}
self._log("INFO", f"Aufnahme gestartet: {name}", name)
self._notify("start", "Aufnahme gestartet", name, rec_id)
@@ -225,74 +228,90 @@ class RecordingManager:
retry_delay = self.config.get("recording", "retry_delay", default=5)
retry = 0
while not stop_event.is_set():
cmd = self._build_ffmpeg_cmd(
stream_url, stream_type, output_file,
extra_ffmpeg_args, segment_mode, segment_duration,
)
try:
log_path = os.path.join(output_dir, f".ffmpeg_{rec_id}.log")
log_fh = open(log_path, "a")
process = subprocess.Popen(
cmd,
stdout=log_fh,
stderr=log_fh,
stdin=subprocess.DEVNULL,
try:
while not stop_event.is_set():
cmd = self._build_ffmpeg_cmd(
stream_url, stream_type, output_file,
extra_ffmpeg_args, segment_mode, segment_duration,
)
except Exception as e:
self._update_recording(rec_id, status="error", error_message=str(e))
self._log("ERROR", f"ffmpeg konnte nicht gestartet werden: {e}", job_name)
self._notify("error", "Aufnahme fehlgeschlagen", f"{job_name}: {e}", rec_id, priority="high")
return
self._processes[rec_id] = process
self._update_recording(rec_id, status="running", pid=process.pid)
try:
log_path = os.path.join(output_dir, f".ffmpeg_{rec_id}.log")
log_fh = open(log_path, "a")
process = subprocess.Popen(
cmd,
stdout=log_fh,
stderr=log_fh,
stdin=subprocess.DEVNULL,
)
except Exception as e:
self._update_recording(rec_id, status="error", error_message=str(e),
file_size=self._finalize_file_size(rec_id, segment_mode))
self._log("ERROR", f"ffmpeg konnte nicht gestartet werden: {e}", job_name)
self._notify("error", "Aufnahme fehlgeschlagen", f"{job_name}: {e}", rec_id, priority="high")
return
process.wait()
retcode = process.returncode
log_fh.close()
self._processes.pop(rec_id, None)
self._processes[rec_id] = process
self._update_recording(rec_id, status="running", pid=process.pid)
if stop_event.is_set() or retcode == 0:
status = "completed" if retcode == 0 else "stopped"
status_de = "abgeschlossen" if status == "completed" else "gestoppt"
self._update_recording(rec_id, status=status)
self._log("INFO", f"Aufnahme {status_de}: {job_name}", job_name)
self._notify("stop", f"Aufnahme {status_de}", job_name, rec_id)
self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
return
process.wait()
retcode = process.returncode
log_fh.close()
self._processes.pop(rec_id, None)
if retcode in (-2, 130, 143, 255):
self._update_recording(rec_id, status="stopped")
self._log("INFO", f"Aufnahme gestoppt: {job_name}", job_name)
self._notify("stop", "Aufnahme gestoppt", job_name, rec_id)
self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
return
if stop_event.is_set() or retcode == 0:
status = "completed" if retcode == 0 else "stopped"
status_de = "abgeschlossen" if status == "completed" else "gestoppt"
self._update_recording(rec_id, status=status,
file_size=self._finalize_file_size(rec_id, segment_mode))
self._log("INFO", f"Aufnahme {status_de}: {job_name}", job_name)
self._notify("stop", f"Aufnahme {status_de}", job_name, rec_id)
self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
return
retry += 1
if max_retries > 0 and retry >= max_retries:
self._update_recording(rec_id, status="error",
error_message=f"Max retries ({max_retries}) reached, exit code {retcode}")
self._log("ERROR", f"Max. Versuche ({max_retries}) erreicht: {job_name}", job_name)
self._notify("error", "Aufnahme fehlgeschlagen", f"{job_name}: Max. Versuche erreicht", rec_id, priority="high")
return
if retcode in (-2, 130, 143, 255):
self._update_recording(rec_id, status="stopped",
file_size=self._finalize_file_size(rec_id, segment_mode))
self._log("INFO", f"Aufnahme gestoppt: {job_name}", job_name)
self._notify("stop", "Aufnahme gestoppt", job_name, rec_id)
self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
return
self._log("WARN", f"Verbindung verloren (Code: {retcode}), Reconnect in {retry_delay}s... [{job_name}]", job_name)
self._notify("error", "Stream abgerissen", f"{job_name}: Reconnect in {retry_delay}s...", rec_id)
if stop_event.wait(timeout=retry_delay):
self._update_recording(rec_id, status="stopped")
return
retry += 1
if max_retries > 0 and retry >= max_retries:
self._update_recording(rec_id, status="error",
error_message=f"Max retries ({max_retries}) reached, exit code {retcode}",
file_size=self._finalize_file_size(rec_id, segment_mode))
self._log("ERROR", f"Max. Versuche ({max_retries}) erreicht: {job_name}", job_name)
self._notify("error", "Aufnahme fehlgeschlagen", f"{job_name}: Max. Versuche erreicht", rec_id, priority="high")
return
sname = safe_name(job_name)
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
ext = Path(output_file).suffix.lstrip(".")
if segment_mode:
output_file = os.path.join(output_dir, f"{sname}_{ts}_seg%03d.{ext}")
else:
output_file = os.path.join(output_dir, f"{sname}_{ts}.{ext}")
self._log("WARN", f"Verbindung verloren (Code: {retcode}), Reconnect in {retry_delay}s... [{job_name}]", job_name)
self._notify("error", "Stream abgerissen", f"{job_name}: Reconnect in {retry_delay}s...", rec_id)
if stop_event.wait(timeout=retry_delay):
self._update_recording(rec_id, status="stopped",
file_size=self._finalize_file_size(rec_id, segment_mode))
self._log("INFO", f"Aufnahme gestoppt: {job_name}", job_name)
self._notify("stop", "Aufnahme gestoppt", job_name, rec_id)
self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
return
self._update_recording(rec_id, status="stopped")
sname = safe_name(job_name)
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
ext = Path(output_file).suffix.lstrip(".")
if segment_mode:
output_file = os.path.join(output_dir, f"{sname}_{ts}_seg%03d.{ext}")
else:
output_file = os.path.join(output_dir, f"{sname}_{ts}.{ext}")
self._output_files.setdefault(rec_id, []).append(output_file)
self._update_recording(rec_id, status="stopped",
file_size=self._finalize_file_size(rec_id, segment_mode))
self._log("INFO", f"Aufnahme gestoppt: {job_name}", job_name)
self._notify("stop", "Aufnahme gestoppt", job_name, rec_id)
self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
finally:
self._cleanup(rec_id)
def _build_ffmpeg_cmd(self, url, stream_type, output_file, extra_args, segment_mode, segment_duration):
cmd = ["ffmpeg", "-nostdin", "-y", "-hide_banner", "-loglevel", "warning"]
@@ -330,9 +349,10 @@ class RecordingManager:
except (ProcessLookupError, OSError):
pass
self._update_recording(rec_id, status="stopped")
self._log("INFO", f"Aufnahme gestoppt (ID: {rec_id})")
self._cleanup(rec_id)
if not process and rec_id not in self._threads:
self._update_recording(rec_id, status="stopped")
self._log("INFO", f"Aufnahme gestoppt (ID: {rec_id})")
self._cleanup(rec_id)
return True
def extend_recording(self, rec_id: int, extra_seconds: int) -> bool:
@@ -433,16 +453,11 @@ class RecordingManager:
total_size = 0
segment_count = 0
if rec.output_dir and os.path.isdir(rec.output_dir):
for f in os.listdir(rec.output_dir):
if f.startswith("."):
continue
fp = os.path.join(rec.output_dir, f)
if os.path.isfile(fp):
total_size += os.path.getsize(fp)
segment_count += 1
elif rec.output_file and os.path.isfile(rec.output_file):
total_size = os.path.getsize(rec.output_file)
if rec.file_size is not None:
total_size = rec.file_size
else:
files = self._output_files.get(rec.id, [rec.output_file] if rec.output_file else [])
total_size, segment_count = self._calc_recording_size(files, rec.segment_mode)
remaining = None
if rec.scheduled_stop and rec.status == "running":
@@ -474,6 +489,29 @@ class RecordingManager:
"remaining_display": format_elapsed(remaining) if remaining is not None else None,
}
def _calc_recording_size(self, output_files: list[str], segment_mode: bool) -> tuple[int, int]:
total_size = 0
segment_count = 0
for of in output_files:
if segment_mode:
pattern = of.replace('%03d', '*')
for f in globmod.glob(pattern):
if os.path.isfile(f):
total_size += os.path.getsize(f)
segment_count += 1
else:
if os.path.isfile(of):
total_size += os.path.getsize(of)
segment_count += 1
return total_size, segment_count
def _finalize_file_size(self, rec_id: int, segment_mode: bool) -> int:
files = self._output_files.get(rec_id, [])
if not files:
return 0
total_size, _ = self._calc_recording_size(files, segment_mode)
return total_size
def _update_recording(self, rec_id: int, **kwargs):
session = get_session()
try:
@@ -505,3 +543,4 @@ class RecordingManager:
self._stop_events.pop(rec_id, None)
self._threads.pop(rec_id, None)
self._rec_options.pop(rec_id, None)
self._output_files.pop(rec_id, None)
+121 -38
View File
@@ -1,5 +1,6 @@
import datetime
from apscheduler.schedulers.background import BackgroundScheduler
from sqlalchemy import or_
from models import Job, Recording, get_session
DAY_MAP = {
@@ -75,6 +76,111 @@ class StreamScheduler:
today = job.schedule_date or today
return f"{job.id}|{job.schedule_once}|{today}|{job.schedule_start}"
def _schedule_state(self, job: dict, now: datetime.datetime | None = None) -> dict:
now = now or datetime.datetime.now()
today_num = now.isoweekday()
today_date = now.strftime("%Y-%m-%d")
now_time = now.strftime("%H:%M")
occ_key = (
f"{job['id']}|{job['schedule_once']}|"
f"{today_date if not job['schedule_once'] else job['schedule_date']}|"
f"{job['schedule_start']}"
)
is_today = False
multi_day = bool(job.get("schedule_end_date") and job["schedule_once"])
if job["schedule_once"]:
if job["schedule_date"] == today_date:
is_today = True
else:
if _matches_day(today_num, job["schedule_days"] or "*"):
is_today = True
in_window = False
is_cross_midnight = False
if is_today:
if multi_day:
now_min = _time_to_minutes(now_time)
start_min = _time_to_minutes(job["schedule_start"])
in_window = now_min >= start_min
elif job["schedule_stop"]:
start_min = _time_to_minutes(job["schedule_start"])
stop_min = _time_to_minutes(job["schedule_stop"])
if stop_min <= start_min:
is_cross_midnight = True
now_min = _time_to_minutes(now_time)
in_window = now_min >= start_min
else:
in_window = _in_time_window(now_time, job["schedule_start"], job["schedule_stop"])
else:
now_min = _time_to_minutes(now_time)
start_min = _time_to_minutes(job["schedule_start"])
in_window = now_min >= start_min
return {
"occ_key": occ_key,
"in_window": in_window,
"multi_day": multi_day,
"is_cross_midnight": is_cross_midnight,
}
def suppress_current_occurrence(self, job_id: int) -> bool:
session = get_session()
try:
j = session.query(Job).filter_by(id=job_id).first()
if not j or not j.schedule_enabled or not j.schedule_start:
return False
job = {
"id": j.id,
"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,
}
finally:
session.close()
state = self._schedule_state(job)
if not state["in_window"]:
return False
self._started_keys.add(state["occ_key"])
return True
def _occurrence_start_utc(self, job: dict, now: datetime.datetime) -> datetime.datetime | None:
date_text = job["schedule_date"] if job["schedule_once"] else now.strftime("%Y-%m-%d")
try:
local_start = datetime.datetime.strptime(
f"{date_text} {job['schedule_start']}",
"%Y-%m-%d %H:%M",
)
except (TypeError, ValueError):
return None
local_utc_offset = datetime.datetime.now() - datetime.datetime.utcnow()
return local_start - local_utc_offset
def _has_recording_for_occurrence(self, job: dict, now: datetime.datetime) -> bool:
occurrence_start = self._occurrence_start_utc(job, now)
if occurrence_start is None:
return False
session = get_session()
try:
return session.query(Recording).filter(
Recording.job_id == job["id"],
or_(
Recording.started_at >= occurrence_start,
Recording.stopped_at >= occurrence_start,
),
).first() is not None
finally:
session.close()
def _is_job_recording(self, job_id: int) -> bool:
session = get_session()
try:
@@ -99,9 +205,6 @@ class StreamScheduler:
def _tick(self):
now = datetime.datetime.now()
today_num = now.isoweekday()
today_date = now.strftime("%Y-%m-%d")
now_time = now.strftime("%H:%M")
session = get_session()
try:
@@ -132,44 +235,19 @@ class StreamScheduler:
if not job["schedule_start"]:
continue
is_today = False
multi_day = bool(job.get("schedule_end_date") and job["schedule_once"])
if job["schedule_once"]:
if job["schedule_date"] == today_date:
is_today = True
else:
if _matches_day(today_num, job["schedule_days"] or "*"):
is_today = True
in_window = False
is_cross_midnight = False
if is_today:
if multi_day:
now_min = _time_to_minutes(now_time)
start_min = _time_to_minutes(job["schedule_start"])
in_window = now_min >= start_min
elif job["schedule_stop"]:
start_min = _time_to_minutes(job["schedule_start"])
stop_min = _time_to_minutes(job["schedule_stop"])
if stop_min <= start_min:
is_cross_midnight = True
now_min = _time_to_minutes(now_time)
in_window = now_min >= start_min
else:
in_window = _in_time_window(now_time, job["schedule_start"], job["schedule_stop"])
else:
now_min = _time_to_minutes(now_time)
start_min = _time_to_minutes(job["schedule_start"])
in_window = now_min >= start_min
occ_key = f"{job['id']}|{job['schedule_once']}|{today_date if not job['schedule_once'] else job['schedule_date']}|{job['schedule_start']}"
state = self._schedule_state(job, now)
in_window = state["in_window"]
occ_key = state["occ_key"]
if in_window:
if occ_key not in self._started_keys and not self._is_job_recording(job["id"]):
if (
occ_key not in self._started_keys
and not self._is_job_recording(job["id"])
and not self._has_recording_for_occurrence(job, now)
):
duration = job["max_duration"]
if not duration and job["schedule_stop"]:
if multi_day:
if state["multi_day"]:
start_dt = datetime.datetime.strptime(
f"{job['schedule_date']} {job['schedule_start']}", "%Y-%m-%d %H:%M")
end_dt = datetime.datetime.strptime(
@@ -204,7 +282,12 @@ class StreamScheduler:
)
self._started_keys.add(occ_key)
else:
if not multi_day and not is_cross_midnight and job["schedule_stop"] and self._is_job_recording(job["id"]):
if (
not state["multi_day"]
and not state["is_cross_midnight"]
and job["schedule_stop"]
and self._is_job_recording(job["id"])
):
rec_id = self._get_active_scheduled_recording_for_job(job["id"])
if rec_id:
self.recorder.stop_recording(rec_id)
+222 -4
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>stream-recorder</title>
<title>Stream-Recorder</title>
<style>
:root {
--bg: #0f1117;
@@ -30,6 +30,7 @@
color: var(--text);
line-height: 1.5;
min-height: 100vh;
padding-bottom: 52px;
display: flex;
flex-direction: column;
}
@@ -338,15 +339,38 @@
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.site-footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
text-align: center;
padding: 12px 16px;
background: rgba(15, 17, 23, 0.96);
border-top: 1px solid var(--border);
color: var(--text-dim);
font-size: 0.75rem;
}
.site-footer a {
color: var(--text-dim);
}
.site-footer a:last-child {
color: var(--accent);
}
</style>
</head>
<body>
<div class="app">
<header>
<h1><span class="rec-dot" id="recDot"></span> stream-recorder</h1>
<h1><span class="rec-dot" id="recDot"></span> Stream-Recorder</h1>
<div class="tabs">
<button class="tab active" onclick="switchTab('dashboard')">Dashboard</button>
<button class="tab" onclick="switchTab('jobs')">Jobs</button>
<button class="tab" onclick="switchTab('stations')">Sender</button>
<button class="tab" onclick="switchTab('logs')">Logs</button>
<button class="tab" onclick="switchTab('settings')">Einstellungen</button>
</div>
@@ -373,6 +397,15 @@
<div id="jobsList"></div>
</div>
<!-- Stations -->
<div class="panel" id="panel-stations">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
<p class="section-title" style="margin:0">Senderbibliothek</p>
<button class="btn btn-primary btn-sm" onclick="openStationModal()">+ Neuer Sender</button>
</div>
<div id="stationsList"></div>
</div>
<!-- Logs -->
<div class="panel" id="panel-logs">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
@@ -402,6 +435,12 @@
<div class="modal-overlay" id="quickRecordModal">
<div class="modal">
<div class="modal-title">Schnellaufnahme</div>
<div class="form-group">
<label>Senderbibliothek</label>
<select id="qr-station" onchange="applyStationToQuickRecord()">
<option value="">URL manuell eingeben</option>
</select>
</div>
<div class="form-group">
<label>Stream-URL *</label>
<input type="text" id="qr-url" placeholder="rtmp://... oder https://...m3u8">
@@ -442,6 +481,12 @@
<input type="hidden" id="job-edit-id">
<div class="form-grid">
<div class="form-group full">
<label>Senderbibliothek</label>
<select id="job-station" onchange="applyStationToJob()">
<option value="">URL manuell eingeben</option>
</select>
</div>
<div class="form-group full">
<label>Stream-URL *</label>
<input type="text" id="job-url" placeholder="rtmp://... oder https://...m3u8">
@@ -574,6 +619,44 @@
</div>
</div>
<!-- Station Modal -->
<div class="modal-overlay" id="stationModal">
<div class="modal">
<div class="modal-title" id="stationModalTitle">Neuer Sender</div>
<input type="hidden" id="station-edit-id">
<div class="form-grid">
<div class="form-group">
<label>Name *</label>
<input type="text" id="station-name" placeholder="Sendername">
</div>
<div class="form-group">
<label>Typ</label>
<select id="station-type">
<option value="auto">Automatisch</option>
<option value="rtmp">RTMP</option>
<option value="hls">HLS</option>
<option value="mp3">MP3</option>
<option value="aac">AAC</option>
<option value="ogg">OGG</option>
<option value="http">HTTP</option>
</select>
</div>
<div class="form-group full">
<label>Stream-URL *</label>
<input type="text" id="station-url" placeholder="rtmp://... oder https://...m3u8">
</div>
<div class="form-group full">
<label>Beschreibung (optional)</label>
<textarea id="station-description" rows="3" placeholder="z.B. Hauptstream, Wochenendprogramm..."></textarea>
</div>
</div>
<div class="modal-actions">
<button class="btn" onclick="closeModal('stationModal')">Abbrechen</button>
<button class="btn btn-primary" onclick="saveStation()">Speichern</button>
</div>
</div>
</div>
<!-- Extend Modal -->
<div class="modal-overlay" id="extendModal">
<div class="modal" style="max-width:400px">
@@ -602,6 +685,7 @@
<script>
const API = '';
let stationsCache = [];
async function api(path, opts = {}) {
const res = await fetch(API + path, {
@@ -620,12 +704,20 @@ function switchTab(name) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
document.querySelector(`#panel-${name}`).classList.add('active');
const tabLabel = {
dashboard: 'dash',
jobs: 'jobs',
stations: 'sender',
logs: 'logs',
settings: 'einst',
}[name] || name;
document.querySelectorAll('.tab').forEach(t => {
if (t.textContent.toLowerCase().includes(name === 'dashboard' ? 'dash' : name === 'settings' ? 'einst' : name))
if (t.textContent.toLowerCase().includes(tabLabel))
t.classList.add('active');
});
if (name === 'logs') loadLogs();
if (name === 'jobs') loadJobs();
if (name === 'stations') loadStations();
if (name === 'settings') loadSettings();
}
@@ -706,6 +798,127 @@ function renderRecent(recs) {
`).join('');
}
// Stations
async function loadStationOptions() {
try {
stationsCache = await api('/api/stations');
populateStationSelects();
} catch (e) {
console.error(e);
}
}
function populateStationSelects() {
['qr-station', 'job-station'].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
const selected = el.value;
el.innerHTML = '<option value="">URL manuell eingeben</option>' + stationsCache.map(s =>
`<option value="${s.id}">${esc(s.name)}${s.description ? ' - ' + esc(s.description) : ''}</option>`
).join('');
if (stationsCache.some(s => String(s.id) === selected)) el.value = selected;
});
}
async function loadStations() {
try {
await loadStationOptions();
const el = document.getElementById('stationsList');
if (!stationsCache.length) {
el.innerHTML = '<div class="empty"><div class="empty-icon">&#128251;</div>Keine Sender gespeichert<br><button class="btn btn-primary" style="margin-top:12px" onclick="openStationModal()">Ersten Sender anlegen</button></div>';
return;
}
el.innerHTML = stationsCache.map(s => `
<div class="card">
<div class="card-header">
<div class="card-title">
${esc(s.name)}
<span class="badge badge-scheduled">${esc(s.stream_type || 'auto')}</span>
</div>
<div class="actions">
<button class="btn btn-sm" onclick="openStationModal(${s.id})">Bearbeiten</button>
<button class="btn btn-sm btn-danger" onclick="deleteStation(${s.id})">Löschen</button>
</div>
</div>
${s.description ? `<div class="meta" style="margin-bottom:4px"><span class="meta-item" style="color:var(--text)">${esc(s.description)}</span></div>` : ''}
<div class="meta">
<span class="meta-item">${esc(s.stream_url)}</span>
</div>
</div>
`).join('');
} catch (e) {
console.error(e);
}
}
function openStationModal(id = null) {
const station = id ? stationsCache.find(s => s.id === id) : null;
document.getElementById('station-edit-id').value = station ? station.id : '';
document.getElementById('stationModalTitle').textContent = station ? 'Sender bearbeiten' : 'Neuer Sender';
document.getElementById('station-name').value = station ? station.name : '';
document.getElementById('station-url').value = station ? station.stream_url : '';
document.getElementById('station-type').value = station ? station.stream_type || 'auto' : 'auto';
document.getElementById('station-description').value = station ? station.description || '' : '';
openModal('stationModal');
}
async function saveStation() {
const editId = document.getElementById('station-edit-id').value;
const data = {
name: document.getElementById('station-name').value,
stream_url: document.getElementById('station-url').value,
stream_type: document.getElementById('station-type').value,
description: document.getElementById('station-description').value,
};
if (!data.name || !data.stream_url) {
alert('Name und URL sind Pflichtfelder');
return;
}
try {
if (editId) {
await api(`/api/stations/${editId}`, { method: 'PUT', body: JSON.stringify(data) });
} else {
await api('/api/stations', { method: 'POST', body: JSON.stringify(data) });
}
closeModal('stationModal');
loadStations();
} catch (e) {
alert('Fehler: ' + e.message);
}
}
async function deleteStation(id) {
if (!confirm('Sender wirklich löschen?')) return;
await api(`/api/stations/${id}`, { method: 'DELETE' });
loadStations();
}
function applyStationToQuickRecord() {
const id = document.getElementById('qr-station').value;
const station = stationsCache.find(s => String(s.id) === id);
if (!station) return;
document.getElementById('qr-url').value = station.stream_url;
document.getElementById('qr-type').value = station.stream_type || 'auto';
if (!document.getElementById('qr-name').value) {
document.getElementById('qr-name').value = station.name;
}
}
function applyStationToJob() {
const id = document.getElementById('job-station').value;
const station = stationsCache.find(s => String(s.id) === id);
if (!station) return;
document.getElementById('job-url').value = station.stream_url;
document.getElementById('job-type').value = station.stream_type || 'auto';
if (!document.getElementById('job-name').value) {
document.getElementById('job-name').value = station.name;
}
if (!document.getElementById('job-description').value && station.description) {
document.getElementById('job-description').value = station.description;
}
}
// Jobs
async function loadJobs() {
try {
@@ -748,8 +961,10 @@ async function loadJobs() {
}
function openJobModal(job = null) {
loadStationOptions();
document.getElementById('job-edit-id').value = job ? job.id : '';
document.getElementById('jobModalTitle').textContent = job ? 'Job bearbeiten' : 'Neuer Job';
document.getElementById('job-station').value = '';
document.getElementById('job-url').value = job ? job.stream_url : '';
document.getElementById('job-name').value = job ? job.name : '';
document.getElementById('job-type').value = job ? job.stream_type : 'auto';
@@ -915,6 +1130,8 @@ function toggleMetadata() {
// Quick Record
function openQuickRecord() {
loadStationOptions();
document.getElementById('qr-station').value = '';
document.getElementById('qr-url').value = '';
document.getElementById('qr-name').value = '';
document.getElementById('qr-type').value = 'auto';
@@ -1121,10 +1338,11 @@ document.querySelectorAll('.modal-overlay').forEach(overlay => {
});
// Auto-refresh
loadStationOptions();
loadDashboard();
setInterval(loadDashboard, 5000);
</script>
<footer style="text-align:center;padding:32px 16px 16px;color:var(--text-dim);font-size:0.75rem">
<footer class="site-footer">
stream-recorder &copy; <a href="https://www.patrick-asmus.de" target="_blank" rel="noopener" style="color:var(--text-dim)">Patrick Asmus</a> &middot; <a href="https://git.techniverse.net/scriptos/stream-recorder" target="_blank" rel="noopener" style="color:var(--accent)">Source</a>
</footer>
</body>