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