Dateigröße pro Aufnahme entkoppelt - feste Größe nach Abschluss
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:
co-authored by
Claude Opus 4.6
parent
1ad87c7186
commit
851ff086ac
+104
-1
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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">📻</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 © <a href="https://www.patrick-asmus.de" target="_blank" rel="noopener" style="color:var(--text-dim)">Patrick Asmus</a> · <a href="https://git.techniverse.net/scriptos/stream-recorder" target="_blank" rel="noopener" style="color:var(--accent)">Source</a>
|
stream-recorder © <a href="https://www.patrick-asmus.de" target="_blank" rel="noopener" style="color:var(--text-dim)">Patrick Asmus</a> · <a href="https://git.techniverse.net/scriptos/stream-recorder" target="_blank" rel="noopener" style="color:var(--accent)">Source</a>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+10
@@ -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 |
|
||||||
|
|||||||
Reference in New Issue
Block a user