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 typing import Optional
from config import Config 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 recorder import RecordingManager, format_elapsed
from scheduler import StreamScheduler from scheduler import StreamScheduler
@@ -83,6 +83,106 @@ async def health():
# --- Jobs CRUD --- # --- 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): class JobCreate(BaseModel):
name: str name: str
stream_url: str stream_url: str
@@ -310,6 +410,9 @@ async def get_recording(rec_id: int):
@app.post("/api/recordings/{rec_id}/stop") @app.post("/api/recordings/{rec_id}/stop")
async def stop_recording(rec_id: int): 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) recorder.stop_recording(rec_id)
return {"message": "Aufnahme gestoppt"} return {"message": "Aufnahme gestoppt"}
+24 -1
View File
@@ -1,5 +1,5 @@
import datetime 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 from sqlalchemy.orm import declarative_base, sessionmaker
Base = declarative_base() Base = declarative_base()
@@ -41,6 +41,18 @@ class Job(Base):
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) 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): class Recording(Base):
__tablename__ = "recordings" __tablename__ = "recordings"
@@ -59,6 +71,7 @@ class Recording(Base):
segment_mode = Column(Boolean, default=False) segment_mode = Column(Boolean, default=False)
is_scheduled = Column(Boolean, default=False) is_scheduled = Column(Boolean, default=False)
error_message = Column(Text, nullable=True) error_message = Column(Text, nullable=True)
file_size = Column(Integer, nullable=True)
class LogEntry(Base): 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}) _engine = create_engine(f"sqlite:///{db_path}", connect_args={"check_same_thread": False})
Base.metadata.create_all(_engine) Base.metadata.create_all(_engine)
_SessionLocal = sessionmaker(bind=_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(): def get_session():
+119 -80
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import datetime import datetime
import glob as globmod
import os import os
import re import re
import signal import signal
@@ -68,6 +69,7 @@ class RecordingManager:
self._ntfy = None self._ntfy = None
self._plik = None self._plik = None
self._rec_options: dict[int, dict] = {} self._rec_options: dict[int, dict] = {}
self._output_files: dict[int, list[str]] = {}
def _get_metadata_monitor(self): def _get_metadata_monitor(self):
if self._metadata_monitor is None: if self._metadata_monitor is None:
@@ -173,6 +175,15 @@ class RecordingManager:
finally: finally:
session.close() 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() stop_event = threading.Event()
self._stop_events[rec_id] = stop_event self._stop_events[rec_id] = stop_event
@@ -185,14 +196,6 @@ class RecordingManager:
self._threads[rec_id] = thread self._threads[rec_id] = thread
thread.start() 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._log("INFO", f"Aufnahme gestartet: {name}", name)
self._notify("start", "Aufnahme gestartet", name, rec_id) 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_delay = self.config.get("recording", "retry_delay", default=5)
retry = 0 retry = 0
while not stop_event.is_set(): try:
cmd = self._build_ffmpeg_cmd( while not stop_event.is_set():
stream_url, stream_type, output_file, cmd = self._build_ffmpeg_cmd(
extra_ffmpeg_args, segment_mode, segment_duration, 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,
) )
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 try:
self._update_recording(rec_id, status="running", pid=process.pid) 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() self._processes[rec_id] = process
retcode = process.returncode self._update_recording(rec_id, status="running", pid=process.pid)
log_fh.close()
self._processes.pop(rec_id, None)
if stop_event.is_set() or retcode == 0: process.wait()
status = "completed" if retcode == 0 else "stopped" retcode = process.returncode
status_de = "abgeschlossen" if status == "completed" else "gestoppt" log_fh.close()
self._update_recording(rec_id, status=status) self._processes.pop(rec_id, None)
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
if retcode in (-2, 130, 143, 255): if stop_event.is_set() or retcode == 0:
self._update_recording(rec_id, status="stopped") status = "completed" if retcode == 0 else "stopped"
self._log("INFO", f"Aufnahme gestoppt: {job_name}", job_name) status_de = "abgeschlossen" if status == "completed" else "gestoppt"
self._notify("stop", "Aufnahme gestoppt", job_name, rec_id) self._update_recording(rec_id, status=status,
self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode) file_size=self._finalize_file_size(rec_id, segment_mode))
return 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 retcode in (-2, 130, 143, 255):
if max_retries > 0 and retry >= max_retries: self._update_recording(rec_id, status="stopped",
self._update_recording(rec_id, status="error", file_size=self._finalize_file_size(rec_id, segment_mode))
error_message=f"Max retries ({max_retries}) reached, exit code {retcode}") self._log("INFO", f"Aufnahme gestoppt: {job_name}", job_name)
self._log("ERROR", f"Max. Versuche ({max_retries}) erreicht: {job_name}", job_name) self._notify("stop", "Aufnahme gestoppt", job_name, rec_id)
self._notify("error", "Aufnahme fehlgeschlagen", f"{job_name}: Max. Versuche erreicht", rec_id, priority="high") self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
return return
self._log("WARN", f"Verbindung verloren (Code: {retcode}), Reconnect in {retry_delay}s... [{job_name}]", job_name) retry += 1
self._notify("error", "Stream abgerissen", f"{job_name}: Reconnect in {retry_delay}s...", rec_id) if max_retries > 0 and retry >= max_retries:
if stop_event.wait(timeout=retry_delay): self._update_recording(rec_id, status="error",
self._update_recording(rec_id, status="stopped") error_message=f"Max retries ({max_retries}) reached, exit code {retcode}",
return 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) self._log("WARN", f"Verbindung verloren (Code: {retcode}), Reconnect in {retry_delay}s... [{job_name}]", job_name)
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") self._notify("error", "Stream abgerissen", f"{job_name}: Reconnect in {retry_delay}s...", rec_id)
ext = Path(output_file).suffix.lstrip(".") if stop_event.wait(timeout=retry_delay):
if segment_mode: self._update_recording(rec_id, status="stopped",
output_file = os.path.join(output_dir, f"{sname}_{ts}_seg%03d.{ext}") file_size=self._finalize_file_size(rec_id, segment_mode))
else: self._log("INFO", f"Aufnahme gestoppt: {job_name}", job_name)
output_file = os.path.join(output_dir, f"{sname}_{ts}.{ext}") 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): def _build_ffmpeg_cmd(self, url, stream_type, output_file, extra_args, segment_mode, segment_duration):
cmd = ["ffmpeg", "-nostdin", "-y", "-hide_banner", "-loglevel", "warning"] cmd = ["ffmpeg", "-nostdin", "-y", "-hide_banner", "-loglevel", "warning"]
@@ -330,9 +349,10 @@ class RecordingManager:
except (ProcessLookupError, OSError): except (ProcessLookupError, OSError):
pass pass
self._update_recording(rec_id, status="stopped") if not process and rec_id not in self._threads:
self._log("INFO", f"Aufnahme gestoppt (ID: {rec_id})") self._update_recording(rec_id, status="stopped")
self._cleanup(rec_id) self._log("INFO", f"Aufnahme gestoppt (ID: {rec_id})")
self._cleanup(rec_id)
return True return True
def extend_recording(self, rec_id: int, extra_seconds: int) -> bool: def extend_recording(self, rec_id: int, extra_seconds: int) -> bool:
@@ -433,16 +453,11 @@ class RecordingManager:
total_size = 0 total_size = 0
segment_count = 0 segment_count = 0
if rec.output_dir and os.path.isdir(rec.output_dir): if rec.file_size is not None:
for f in os.listdir(rec.output_dir): total_size = rec.file_size
if f.startswith("."): else:
continue files = self._output_files.get(rec.id, [rec.output_file] if rec.output_file else [])
fp = os.path.join(rec.output_dir, f) total_size, segment_count = self._calc_recording_size(files, rec.segment_mode)
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)
remaining = None remaining = None
if rec.scheduled_stop and rec.status == "running": 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, "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): def _update_recording(self, rec_id: int, **kwargs):
session = get_session() session = get_session()
try: try:
@@ -505,3 +543,4 @@ class RecordingManager:
self._stop_events.pop(rec_id, None) self._stop_events.pop(rec_id, None)
self._threads.pop(rec_id, None) self._threads.pop(rec_id, None)
self._rec_options.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 import datetime
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from sqlalchemy import or_
from models import Job, Recording, get_session from models import Job, Recording, get_session
DAY_MAP = { DAY_MAP = {
@@ -75,6 +76,111 @@ class StreamScheduler:
today = job.schedule_date or today today = job.schedule_date or today
return f"{job.id}|{job.schedule_once}|{today}|{job.schedule_start}" 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: def _is_job_recording(self, job_id: int) -> bool:
session = get_session() session = get_session()
try: try:
@@ -99,9 +205,6 @@ class StreamScheduler:
def _tick(self): def _tick(self):
now = datetime.datetime.now() now = datetime.datetime.now()
today_num = now.isoweekday()
today_date = now.strftime("%Y-%m-%d")
now_time = now.strftime("%H:%M")
session = get_session() session = get_session()
try: try:
@@ -132,44 +235,19 @@ class StreamScheduler:
if not job["schedule_start"]: if not job["schedule_start"]:
continue continue
is_today = False state = self._schedule_state(job, now)
multi_day = bool(job.get("schedule_end_date") and job["schedule_once"]) in_window = state["in_window"]
occ_key = state["occ_key"]
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']}"
if in_window: 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"] duration = job["max_duration"]
if not duration and job["schedule_stop"]: if not duration and job["schedule_stop"]:
if multi_day: if state["multi_day"]:
start_dt = datetime.datetime.strptime( start_dt = datetime.datetime.strptime(
f"{job['schedule_date']} {job['schedule_start']}", "%Y-%m-%d %H:%M") f"{job['schedule_date']} {job['schedule_start']}", "%Y-%m-%d %H:%M")
end_dt = datetime.datetime.strptime( end_dt = datetime.datetime.strptime(
@@ -204,7 +282,12 @@ class StreamScheduler:
) )
self._started_keys.add(occ_key) self._started_keys.add(occ_key)
else: 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"]) rec_id = self._get_active_scheduled_recording_for_job(job["id"])
if rec_id: if rec_id:
self.recorder.stop_recording(rec_id) self.recorder.stop_recording(rec_id)
+222 -4
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>stream-recorder</title> <title>Stream-Recorder</title>
<style> <style>
:root { :root {
--bg: #0f1117; --bg: #0f1117;
@@ -30,6 +30,7 @@
color: var(--text); color: var(--text);
line-height: 1.5; line-height: 1.5;
min-height: 100vh; min-height: 100vh;
padding-bottom: 52px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@@ -338,15 +339,38 @@
padding-bottom: 8px; padding-bottom: 8px;
border-bottom: 1px solid var(--border); 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> </style>
</head> </head>
<body> <body>
<div class="app"> <div class="app">
<header> <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"> <div class="tabs">
<button class="tab active" onclick="switchTab('dashboard')">Dashboard</button> <button class="tab active" onclick="switchTab('dashboard')">Dashboard</button>
<button class="tab" onclick="switchTab('jobs')">Jobs</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('logs')">Logs</button>
<button class="tab" onclick="switchTab('settings')">Einstellungen</button> <button class="tab" onclick="switchTab('settings')">Einstellungen</button>
</div> </div>
@@ -373,6 +397,15 @@
<div id="jobsList"></div> <div id="jobsList"></div>
</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 --> <!-- Logs -->
<div class="panel" id="panel-logs"> <div class="panel" id="panel-logs">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px"> <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-overlay" id="quickRecordModal">
<div class="modal"> <div class="modal">
<div class="modal-title">Schnellaufnahme</div> <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"> <div class="form-group">
<label>Stream-URL *</label> <label>Stream-URL *</label>
<input type="text" id="qr-url" placeholder="rtmp://... oder https://...m3u8"> <input type="text" id="qr-url" placeholder="rtmp://... oder https://...m3u8">
@@ -442,6 +481,12 @@
<input type="hidden" id="job-edit-id"> <input type="hidden" id="job-edit-id">
<div class="form-grid"> <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"> <div class="form-group full">
<label>Stream-URL *</label> <label>Stream-URL *</label>
<input type="text" id="job-url" placeholder="rtmp://... oder https://...m3u8"> <input type="text" id="job-url" placeholder="rtmp://... oder https://...m3u8">
@@ -574,6 +619,44 @@
</div> </div>
</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 --> <!-- Extend Modal -->
<div class="modal-overlay" id="extendModal"> <div class="modal-overlay" id="extendModal">
<div class="modal" style="max-width:400px"> <div class="modal" style="max-width:400px">
@@ -602,6 +685,7 @@
<script> <script>
const API = ''; const API = '';
let stationsCache = [];
async function api(path, opts = {}) { async function api(path, opts = {}) {
const res = await fetch(API + path, { const res = await fetch(API + path, {
@@ -620,12 +704,20 @@ function switchTab(name) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active')); document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
document.querySelector(`#panel-${name}`).classList.add('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 => { 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'); t.classList.add('active');
}); });
if (name === 'logs') loadLogs(); if (name === 'logs') loadLogs();
if (name === 'jobs') loadJobs(); if (name === 'jobs') loadJobs();
if (name === 'stations') loadStations();
if (name === 'settings') loadSettings(); if (name === 'settings') loadSettings();
} }
@@ -706,6 +798,127 @@ function renderRecent(recs) {
`).join(''); `).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 // Jobs
async function loadJobs() { async function loadJobs() {
try { try {
@@ -748,8 +961,10 @@ async function loadJobs() {
} }
function openJobModal(job = null) { function openJobModal(job = null) {
loadStationOptions();
document.getElementById('job-edit-id').value = job ? job.id : ''; document.getElementById('job-edit-id').value = job ? job.id : '';
document.getElementById('jobModalTitle').textContent = job ? 'Job bearbeiten' : 'Neuer Job'; 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-url').value = job ? job.stream_url : '';
document.getElementById('job-name').value = job ? job.name : ''; document.getElementById('job-name').value = job ? job.name : '';
document.getElementById('job-type').value = job ? job.stream_type : 'auto'; document.getElementById('job-type').value = job ? job.stream_type : 'auto';
@@ -915,6 +1130,8 @@ function toggleMetadata() {
// Quick Record // Quick Record
function openQuickRecord() { function openQuickRecord() {
loadStationOptions();
document.getElementById('qr-station').value = '';
document.getElementById('qr-url').value = ''; document.getElementById('qr-url').value = '';
document.getElementById('qr-name').value = ''; document.getElementById('qr-name').value = '';
document.getElementById('qr-type').value = 'auto'; document.getElementById('qr-type').value = 'auto';
@@ -1121,10 +1338,11 @@ document.querySelectorAll('.modal-overlay').forEach(overlay => {
}); });
// Auto-refresh // Auto-refresh
loadStationOptions();
loadDashboard(); loadDashboard();
setInterval(loadDashboard, 5000); setInterval(loadDashboard, 5000);
</script> </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> 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> </footer>
</body> </body>
+10
View File
@@ -23,6 +23,16 @@ Alle Funktionen des Web-UI sind auch per REST-API verfügbar. Basis-URL: `http:/
| `DELETE` | `/api/jobs/:id` | Job löschen | | `DELETE` | `/api/jobs/:id` | Job löschen |
| `POST` | `/api/jobs/:id/record` | Job-Aufnahme starten | | `POST` | `/api/jobs/:id/record` | Job-Aufnahme starten |
### Senderbibliothek
| Methode | Pfad | Beschreibung |
|---------|------|-------------|
| `GET` | `/api/stations` | Alle gespeicherten Sender auflisten |
| `POST` | `/api/stations` | Neuen Sender erstellen |
| `GET` | `/api/stations/:id` | Sender-Details abrufen |
| `PUT` | `/api/stations/:id` | Sender aktualisieren |
| `DELETE` | `/api/stations/:id` | Sender löschen |
### Schnellaufnahme ### Schnellaufnahme
| Methode | Pfad | Beschreibung | | Methode | Pfad | Beschreibung |