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():
+63 -24
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,6 +228,7 @@ 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
try:
while not stop_event.is_set(): while not stop_event.is_set():
cmd = self._build_ffmpeg_cmd( cmd = self._build_ffmpeg_cmd(
stream_url, stream_type, output_file, stream_url, stream_type, output_file,
@@ -241,7 +245,8 @@ class RecordingManager:
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
) )
except Exception as e: except Exception as e:
self._update_recording(rec_id, status="error", error_message=str(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._log("ERROR", f"ffmpeg konnte nicht gestartet werden: {e}", job_name)
self._notify("error", "Aufnahme fehlgeschlagen", f"{job_name}: {e}", rec_id, priority="high") self._notify("error", "Aufnahme fehlgeschlagen", f"{job_name}: {e}", rec_id, priority="high")
return return
@@ -257,14 +262,16 @@ class RecordingManager:
if stop_event.is_set() or retcode == 0: if stop_event.is_set() or retcode == 0:
status = "completed" if retcode == 0 else "stopped" status = "completed" if retcode == 0 else "stopped"
status_de = "abgeschlossen" if status == "completed" else "gestoppt" status_de = "abgeschlossen" if status == "completed" else "gestoppt"
self._update_recording(rec_id, status=status) 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._log("INFO", f"Aufnahme {status_de}: {job_name}", job_name)
self._notify("stop", f"Aufnahme {status_de}", job_name, rec_id) 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) self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
return return
if retcode in (-2, 130, 143, 255): if retcode in (-2, 130, 143, 255):
self._update_recording(rec_id, status="stopped") 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._log("INFO", f"Aufnahme gestoppt: {job_name}", job_name)
self._notify("stop", "Aufnahme gestoppt", job_name, rec_id) self._notify("stop", "Aufnahme gestoppt", job_name, rec_id)
self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode) self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
@@ -273,7 +280,8 @@ class RecordingManager:
retry += 1 retry += 1
if max_retries > 0 and retry >= max_retries: if max_retries > 0 and retry >= max_retries:
self._update_recording(rec_id, status="error", self._update_recording(rec_id, status="error",
error_message=f"Max retries ({max_retries}) reached, exit code {retcode}") 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._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") self._notify("error", "Aufnahme fehlgeschlagen", f"{job_name}: Max. Versuche erreicht", rec_id, priority="high")
return return
@@ -281,7 +289,11 @@ class RecordingManager:
self._log("WARN", f"Verbindung verloren (Code: {retcode}), Reconnect in {retry_delay}s... [{job_name}]", job_name) 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) self._notify("error", "Stream abgerissen", f"{job_name}: Reconnect in {retry_delay}s...", rec_id)
if stop_event.wait(timeout=retry_delay): if stop_event.wait(timeout=retry_delay):
self._update_recording(rec_id, status="stopped") 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 return
sname = safe_name(job_name) sname = safe_name(job_name)
@@ -291,8 +303,15 @@ class RecordingManager:
output_file = os.path.join(output_dir, f"{sname}_{ts}_seg%03d.{ext}") output_file = os.path.join(output_dir, f"{sname}_{ts}_seg%03d.{ext}")
else: else:
output_file = os.path.join(output_dir, f"{sname}_{ts}.{ext}") 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") 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,6 +349,7 @@ class RecordingManager:
except (ProcessLookupError, OSError): except (ProcessLookupError, OSError):
pass pass
if not process and rec_id not in self._threads:
self._update_recording(rec_id, status="stopped") self._update_recording(rec_id, status="stopped")
self._log("INFO", f"Aufnahme gestoppt (ID: {rec_id})") self._log("INFO", f"Aufnahme gestoppt (ID: {rec_id})")
self._cleanup(rec_id) self._cleanup(rec_id)
@@ -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)
+139 -56
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,62 +76,17 @@ 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 _is_job_recording(self, job_id: int) -> bool: def _schedule_state(self, job: dict, now: datetime.datetime | None = None) -> dict:
session = get_session() now = now or datetime.datetime.now()
try:
return session.query(Recording).filter(
Recording.job_id == job_id,
Recording.status.in_(["running", "starting"]),
).first() is not None
finally:
session.close()
def _get_active_scheduled_recording_for_job(self, job_id: int):
session = get_session()
try:
rec = session.query(Recording).filter(
Recording.job_id == job_id,
Recording.status.in_(["running", "starting"]),
Recording.is_scheduled == True,
).first()
return rec.id if rec else None
finally:
session.close()
def _tick(self):
now = datetime.datetime.now()
today_num = now.isoweekday() today_num = now.isoweekday()
today_date = now.strftime("%Y-%m-%d") today_date = now.strftime("%Y-%m-%d")
now_time = now.strftime("%H:%M") now_time = now.strftime("%H:%M")
session = get_session() occ_key = (
try: f"{job['id']}|{job['schedule_once']}|"
jobs = session.query(Job).filter_by(schedule_enabled=True).all() f"{today_date if not job['schedule_once'] else job['schedule_date']}|"
job_list = [] f"{job['schedule_start']}"
for j in jobs: )
job_list.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_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,
"description": j.description,
"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,
"ntfy_enabled": j.ntfy_enabled,
"plik_enabled": j.plik_enabled,
"delete_after_upload": j.delete_after_upload,
})
finally:
session.close()
for job in job_list:
if not job["schedule_start"]:
continue
is_today = False is_today = False
multi_day = bool(job.get("schedule_end_date") and job["schedule_once"]) multi_day = bool(job.get("schedule_end_date") and job["schedule_once"])
@@ -163,13 +119,135 @@ class StreamScheduler:
start_min = _time_to_minutes(job["schedule_start"]) start_min = _time_to_minutes(job["schedule_start"])
in_window = now_min >= start_min 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']}" 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:
return session.query(Recording).filter(
Recording.job_id == job_id,
Recording.status.in_(["running", "starting"]),
).first() is not None
finally:
session.close()
def _get_active_scheduled_recording_for_job(self, job_id: int):
session = get_session()
try:
rec = session.query(Recording).filter(
Recording.job_id == job_id,
Recording.status.in_(["running", "starting"]),
Recording.is_scheduled == True,
).first()
return rec.id if rec else None
finally:
session.close()
def _tick(self):
now = datetime.datetime.now()
session = get_session()
try:
jobs = session.query(Job).filter_by(schedule_enabled=True).all()
job_list = []
for j in jobs:
job_list.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_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,
"description": j.description,
"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,
"ntfy_enabled": j.ntfy_enabled,
"plik_enabled": j.plik_enabled,
"delete_after_upload": j.delete_after_upload,
})
finally:
session.close()
for job in job_list:
if not job["schedule_start"]:
continue
state = self._schedule_state(job, now)
in_window = state["in_window"]
occ_key = state["occ_key"]
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 |