Release: v3.1.0
This commit is contained in:
+22
-19
@@ -17,6 +17,8 @@ from models import init_db, get_session, Job, Station, Recording as RecordingMod
|
||||
from recorder import RecordingManager, format_elapsed
|
||||
from scheduler import StreamScheduler
|
||||
|
||||
APP_VERSION = "3.0.0"
|
||||
|
||||
DATA_DIR = os.environ.get("DATA_DIR", "/app/data")
|
||||
STATIC_DIR = os.environ.get("STATIC_DIR", "/app/frontend/static")
|
||||
|
||||
@@ -37,7 +39,7 @@ async def lifespan(app):
|
||||
scheduler.stop()
|
||||
|
||||
|
||||
app = FastAPI(title="stream-recorder", version="3.0.0", lifespan=lifespan)
|
||||
app = FastAPI(title="stream-recorder", version=APP_VERSION, lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -78,7 +80,7 @@ async def auth_middleware(request: Request, call_next):
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok", "version": "3.0.0"}
|
||||
return {"status": "ok", "version": APP_VERSION}
|
||||
|
||||
|
||||
# --- Jobs CRUD ---
|
||||
@@ -198,14 +200,13 @@ class JobCreate(BaseModel):
|
||||
schedule_start: str = ""
|
||||
schedule_stop: str = ""
|
||||
schedule_end_date: str = ""
|
||||
pre_buffer: int = 0
|
||||
post_buffer: int = 0
|
||||
description: str = ""
|
||||
ntfy_enabled: bool = False
|
||||
plik_enabled: bool = False
|
||||
delete_after_upload: bool = False
|
||||
metadata_monitor_enabled: bool = False
|
||||
metadata_pattern: str = ""
|
||||
metadata_grace_period: int = 300
|
||||
metadata_poll_interval: int = 30
|
||||
station_id: Optional[int] = None
|
||||
|
||||
|
||||
class JobUpdate(JobCreate):
|
||||
@@ -217,6 +218,13 @@ async def list_jobs():
|
||||
session = get_session()
|
||||
try:
|
||||
jobs = session.query(Job).order_by(Job.id).all()
|
||||
|
||||
station_ids = {j.station_id for j in jobs if j.station_id}
|
||||
station_names = {}
|
||||
if station_ids:
|
||||
for s in session.query(Station).filter(Station.id.in_(station_ids)).all():
|
||||
station_names[s.id] = s.name
|
||||
|
||||
result = []
|
||||
for j in jobs:
|
||||
is_recording = session.query(
|
||||
@@ -239,14 +247,14 @@ async def list_jobs():
|
||||
"schedule_start": j.schedule_start,
|
||||
"schedule_stop": j.schedule_stop,
|
||||
"schedule_end_date": j.schedule_end_date,
|
||||
"pre_buffer": j.pre_buffer or 0,
|
||||
"post_buffer": j.post_buffer or 0,
|
||||
"description": j.description,
|
||||
"ntfy_enabled": j.ntfy_enabled,
|
||||
"plik_enabled": j.plik_enabled,
|
||||
"delete_after_upload": j.delete_after_upload,
|
||||
"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,
|
||||
"station_id": j.station_id,
|
||||
"station_name": station_names.get(j.station_id) if j.station_id else None,
|
||||
"is_recording": is_recording,
|
||||
"created_at": j.created_at.isoformat() if j.created_at else None,
|
||||
})
|
||||
@@ -282,13 +290,12 @@ async def get_job(job_id: int):
|
||||
"schedule_enabled": job.schedule_enabled, "schedule_once": job.schedule_once,
|
||||
"schedule_date": job.schedule_date, "schedule_days": job.schedule_days,
|
||||
"schedule_start": job.schedule_start, "schedule_stop": job.schedule_stop,
|
||||
"schedule_end_date": job.schedule_end_date, "description": job.description,
|
||||
"schedule_end_date": job.schedule_end_date,
|
||||
"pre_buffer": job.pre_buffer or 0, "post_buffer": job.post_buffer or 0,
|
||||
"description": job.description,
|
||||
"ntfy_enabled": job.ntfy_enabled,
|
||||
"plik_enabled": job.plik_enabled, "delete_after_upload": job.delete_after_upload,
|
||||
"metadata_monitor_enabled": job.metadata_monitor_enabled,
|
||||
"metadata_pattern": job.metadata_pattern,
|
||||
"metadata_grace_period": job.metadata_grace_period,
|
||||
"metadata_poll_interval": job.metadata_poll_interval,
|
||||
"station_id": job.station_id,
|
||||
}
|
||||
finally:
|
||||
session.close()
|
||||
@@ -354,10 +361,6 @@ async def start_job_recording(job_id: int):
|
||||
"max_duration": duration, "extra_ffmpeg_args": job.extra_ffmpeg_args,
|
||||
"segment_duration": job.segment_duration,
|
||||
"description": job.description,
|
||||
"metadata_monitor_enabled": job.metadata_monitor_enabled,
|
||||
"metadata_pattern": job.metadata_pattern,
|
||||
"metadata_grace_period": job.metadata_grace_period or 300,
|
||||
"metadata_poll_interval": job.metadata_poll_interval or 30,
|
||||
"ntfy_enabled": job.ntfy_enabled,
|
||||
"plik_enabled": job.plik_enabled,
|
||||
"delete_after_upload": job.delete_after_upload,
|
||||
|
||||
@@ -26,6 +26,10 @@ DEFAULT_CONFIG = {
|
||||
"api_key": "",
|
||||
"ttl": "30d",
|
||||
},
|
||||
"schedule": {
|
||||
"default_pre_buffer": 5,
|
||||
"default_post_buffer": 5,
|
||||
},
|
||||
"logging": {
|
||||
"level": "INFO",
|
||||
},
|
||||
|
||||
+17
-9
@@ -25,6 +25,9 @@ class Job(Base):
|
||||
schedule_stop = Column(String(5), default="")
|
||||
schedule_end_date = Column(String(10), default="")
|
||||
|
||||
pre_buffer = Column(Integer, default=0)
|
||||
post_buffer = Column(Integer, default=0)
|
||||
|
||||
description = Column(Text, default="")
|
||||
|
||||
plik_enabled = Column(Boolean, default=False)
|
||||
@@ -32,10 +35,7 @@ class Job(Base):
|
||||
|
||||
ntfy_enabled = Column(Boolean, default=False)
|
||||
|
||||
metadata_monitor_enabled = Column(Boolean, default=False)
|
||||
metadata_pattern = Column(String(200), default="")
|
||||
metadata_grace_period = Column(Integer, default=300)
|
||||
metadata_poll_interval = Column(Integer, default=30)
|
||||
station_id = Column(Integer, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
|
||||
@@ -72,6 +72,7 @@ class Recording(Base):
|
||||
is_scheduled = Column(Boolean, default=False)
|
||||
error_message = Column(Text, nullable=True)
|
||||
file_size = Column(Integer, nullable=True)
|
||||
plik_url = Column(String(1000), nullable=True)
|
||||
|
||||
|
||||
class LogEntry(Base):
|
||||
@@ -98,11 +99,18 @@ def init_db(db_path: str):
|
||||
|
||||
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
|
||||
for stmt in [
|
||||
"ALTER TABLE recordings ADD COLUMN file_size INTEGER",
|
||||
"ALTER TABLE jobs ADD COLUMN station_id INTEGER",
|
||||
"ALTER TABLE jobs ADD COLUMN pre_buffer INTEGER DEFAULT 0",
|
||||
"ALTER TABLE jobs ADD COLUMN post_buffer INTEGER DEFAULT 0",
|
||||
"ALTER TABLE recordings ADD COLUMN plik_url VARCHAR(1000)",
|
||||
]:
|
||||
try:
|
||||
conn.execute(text(stmt))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_session():
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
"""
|
||||
Euer-Radio Metadata-Monitor Plugin.
|
||||
|
||||
Polls RTMP stream metadata via ffprobe to detect whether a specific show
|
||||
is currently live. When the show ends (pattern disappears from metadata),
|
||||
the recording is stopped after a configurable grace period.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional, Callable
|
||||
|
||||
|
||||
def probe_stream_title(stream_url: str, timeout: int = 10) -> Optional[str]:
|
||||
"""Query the current title tag from a live stream via ffprobe."""
|
||||
cmd = [
|
||||
"ffprobe", "-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_entries", "format_tags",
|
||||
stream_url,
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
data = json.loads(result.stdout)
|
||||
return data.get("format", {}).get("tags", {}).get("title")
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError, Exception):
|
||||
return None
|
||||
|
||||
|
||||
def title_matches_pattern(title: str, pattern: str) -> bool:
|
||||
"""
|
||||
Check whether a stream title matches the configured show pattern.
|
||||
|
||||
TODO: Finalize once we've seen real live-show metadata from euer.tv.
|
||||
Current assumption: the title contains the pattern string as a prefix
|
||||
or substring, e.g. "Donnerwolke live - Artist - Song".
|
||||
"""
|
||||
if not title or not pattern:
|
||||
return False
|
||||
return pattern.lower() in title.lower()
|
||||
|
||||
|
||||
class MetadataMonitor:
|
||||
"""Monitors a stream's metadata and stops the recording when a show ends."""
|
||||
|
||||
def __init__(self, log_fn: Callable, stop_fn: Callable):
|
||||
self._log = log_fn
|
||||
self._stop_recording = stop_fn
|
||||
self._monitors: dict[int, threading.Thread] = {}
|
||||
self._cancel_events: dict[int, threading.Event] = {}
|
||||
|
||||
def start_monitoring(
|
||||
self,
|
||||
rec_id: int,
|
||||
stream_url: str,
|
||||
pattern: str,
|
||||
grace_period: int = 300,
|
||||
poll_interval: int = 30,
|
||||
job_name: str = "",
|
||||
):
|
||||
if rec_id in self._monitors:
|
||||
return
|
||||
|
||||
cancel = threading.Event()
|
||||
self._cancel_events[rec_id] = cancel
|
||||
|
||||
thread = threading.Thread(
|
||||
target=self._monitor_loop,
|
||||
args=(rec_id, stream_url, pattern, grace_period, poll_interval, cancel, job_name),
|
||||
daemon=True,
|
||||
)
|
||||
self._monitors[rec_id] = thread
|
||||
thread.start()
|
||||
self._log("INFO", f"Metadata-Monitor gestartet für '{job_name}' (Pattern: '{pattern}')", job_name)
|
||||
|
||||
def stop_monitoring(self, rec_id: int):
|
||||
cancel = self._cancel_events.pop(rec_id, None)
|
||||
if cancel:
|
||||
cancel.set()
|
||||
self._monitors.pop(rec_id, None)
|
||||
|
||||
def is_monitoring(self, rec_id: int) -> bool:
|
||||
return rec_id in self._monitors
|
||||
|
||||
def _monitor_loop(
|
||||
self,
|
||||
rec_id: int,
|
||||
stream_url: str,
|
||||
pattern: str,
|
||||
grace_period: int,
|
||||
poll_interval: int,
|
||||
cancel: threading.Event,
|
||||
job_name: str,
|
||||
):
|
||||
grace_start = None
|
||||
|
||||
while not cancel.is_set():
|
||||
title = probe_stream_title(stream_url)
|
||||
|
||||
if title is None:
|
||||
if cancel.wait(timeout=poll_interval):
|
||||
break
|
||||
continue
|
||||
|
||||
if title_matches_pattern(title, pattern):
|
||||
if grace_start is not None:
|
||||
self._log("INFO",
|
||||
f"Show '{pattern}' wieder erkannt, Karenz abgebrochen [{job_name}]",
|
||||
job_name)
|
||||
grace_start = None
|
||||
else:
|
||||
if grace_start is None:
|
||||
grace_start = time.time()
|
||||
self._log("INFO",
|
||||
f"Show '{pattern}' nicht mehr erkannt, Karenzzeit läuft ({grace_period}s) [{job_name}]",
|
||||
job_name)
|
||||
elif time.time() - grace_start >= grace_period:
|
||||
self._log("INFO",
|
||||
f"Karenzzeit abgelaufen, Aufnahme wird gestoppt [{job_name}]",
|
||||
job_name)
|
||||
self._stop_recording(rec_id)
|
||||
break
|
||||
|
||||
if cancel.wait(timeout=poll_interval):
|
||||
break
|
||||
|
||||
self._monitors.pop(rec_id, None)
|
||||
self._cancel_events.pop(rec_id, None)
|
||||
+16
-10
@@ -95,7 +95,8 @@ class PlikUploader:
|
||||
self._notify = notify_fn
|
||||
|
||||
def upload_recording(self, output_dir: str, output_file: str, job_name: str,
|
||||
segment_mode: bool, delete_after: bool, ntfy_enabled: bool):
|
||||
segment_mode: bool, delete_after: bool, ntfy_enabled: bool,
|
||||
save_url_fn: Callable = None):
|
||||
plik_url = self.config.get("plik", "url", default="")
|
||||
if not plik_url:
|
||||
self._log("WARN", f"Plik-Upload übersprungen: Keine URL konfiguriert [{job_name}]", job_name)
|
||||
@@ -107,15 +108,15 @@ class PlikUploader:
|
||||
def _do_upload():
|
||||
try:
|
||||
if segment_mode:
|
||||
self._upload_segments(output_dir, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled)
|
||||
self._upload_segments(output_dir, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled, save_url_fn)
|
||||
else:
|
||||
self._upload_single(output_file, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled)
|
||||
self._upload_single(output_file, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled, save_url_fn)
|
||||
except Exception as e:
|
||||
self._log("ERROR", f"Plik-Upload Fehler: {e} [{job_name}]", job_name)
|
||||
|
||||
threading.Thread(target=_do_upload, daemon=True).start()
|
||||
|
||||
def _upload_single(self, file_path, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled):
|
||||
def _upload_single(self, file_path, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled, save_url_fn=None):
|
||||
if not os.path.isfile(file_path) or os.path.getsize(file_path) == 0:
|
||||
self._log("WARN", f"Plik-Upload übersprungen: Datei fehlt oder leer [{job_name}]", job_name)
|
||||
return
|
||||
@@ -127,6 +128,8 @@ class PlikUploader:
|
||||
self._log("INFO", f"Plik-Upload abgeschlossen: {result['browser_url']} [{job_name}]", job_name)
|
||||
if result.get("download_url"):
|
||||
self._log("INFO", f"Plik-Download: {result['download_url']} [{job_name}]", job_name)
|
||||
if save_url_fn:
|
||||
save_url_fn(result["browser_url"])
|
||||
if self._notify:
|
||||
self._notify("upload", "Plik-Upload", f"{job_name}: {result['browser_url']}", ntfy_enabled)
|
||||
if delete_after:
|
||||
@@ -135,7 +138,7 @@ class PlikUploader:
|
||||
else:
|
||||
self._log("ERROR", f"Plik-Upload fehlgeschlagen [{job_name}]", job_name)
|
||||
|
||||
def _upload_segments(self, output_dir, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled):
|
||||
def _upload_segments(self, output_dir, job_name, plik_url, api_key, ttl, delete_after, ntfy_enabled, save_url_fn=None):
|
||||
if not os.path.isdir(output_dir):
|
||||
return
|
||||
|
||||
@@ -161,8 +164,11 @@ class PlikUploader:
|
||||
else:
|
||||
self._log("ERROR", f"Plik Segment-Upload fehlgeschlagen: {fname} [{job_name}]", job_name)
|
||||
|
||||
if uploaded > 0 and self._notify:
|
||||
msg = f"{job_name}: {uploaded} Segment(e) hochgeladen"
|
||||
if last_browser_url:
|
||||
msg += f"\n{last_browser_url}"
|
||||
self._notify("upload", "Plik-Upload", msg, ntfy_enabled)
|
||||
if uploaded > 0:
|
||||
if save_url_fn and last_browser_url:
|
||||
save_url_fn(last_browser_url)
|
||||
if self._notify:
|
||||
msg = f"{job_name}: {uploaded} Segment(e) hochgeladen"
|
||||
if last_browser_url:
|
||||
msg += f"\n{last_browser_url}"
|
||||
self._notify("upload", "Plik-Upload", msg, ntfy_enabled)
|
||||
|
||||
+2
-27
@@ -65,21 +65,11 @@ class RecordingManager:
|
||||
self._processes: dict[int, subprocess.Popen] = {}
|
||||
self._stop_events: dict[int, threading.Event] = {}
|
||||
self._threads: dict[int, threading.Thread] = {}
|
||||
self._metadata_monitor = None
|
||||
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:
|
||||
from plugins.euer_radio import MetadataMonitor
|
||||
self._metadata_monitor = MetadataMonitor(
|
||||
log_fn=self._log,
|
||||
stop_fn=self.stop_recording,
|
||||
)
|
||||
return self._metadata_monitor
|
||||
|
||||
def _get_ntfy(self):
|
||||
if self._ntfy is None:
|
||||
from plugins.ntfy import NtfyNotifier
|
||||
@@ -126,10 +116,6 @@ class RecordingManager:
|
||||
segment_duration: Optional[int] = None,
|
||||
is_scheduled: bool = False,
|
||||
description: str = "",
|
||||
metadata_monitor_enabled: bool = False,
|
||||
metadata_pattern: str = "",
|
||||
metadata_grace_period: int = 300,
|
||||
metadata_poll_interval: int = 30,
|
||||
ntfy_enabled: bool = False,
|
||||
plik_enabled: bool = False,
|
||||
delete_after_upload: bool = False,
|
||||
@@ -199,16 +185,6 @@ class RecordingManager:
|
||||
self._log("INFO", f"Aufnahme gestartet: {name}", name)
|
||||
self._notify("start", "Aufnahme gestartet", name, rec_id)
|
||||
|
||||
if metadata_monitor_enabled and metadata_pattern:
|
||||
self._get_metadata_monitor().start_monitoring(
|
||||
rec_id=rec_id,
|
||||
stream_url=stream_url,
|
||||
pattern=metadata_pattern,
|
||||
grace_period=metadata_grace_period,
|
||||
poll_interval=metadata_poll_interval,
|
||||
job_name=name,
|
||||
)
|
||||
|
||||
return {"recording_id": rec_id, "output_dir": output_dir, "output_file": output_file}
|
||||
|
||||
def _recording_loop(
|
||||
@@ -331,9 +307,6 @@ class RecordingManager:
|
||||
return cmd
|
||||
|
||||
def stop_recording(self, rec_id: int) -> bool:
|
||||
if self._metadata_monitor:
|
||||
self._metadata_monitor.stop_monitoring(rec_id)
|
||||
|
||||
stop_event = self._stop_events.get(rec_id)
|
||||
if stop_event:
|
||||
stop_event.set()
|
||||
@@ -487,6 +460,7 @@ class RecordingManager:
|
||||
"segment_count": segment_count if rec.segment_mode else None,
|
||||
"remaining": remaining,
|
||||
"remaining_display": format_elapsed(remaining) if remaining is not None else None,
|
||||
"plik_url": rec.plik_url,
|
||||
}
|
||||
|
||||
def _calc_recording_size(self, output_files: list[str], segment_mode: bool) -> tuple[int, int]:
|
||||
@@ -536,6 +510,7 @@ class RecordingManager:
|
||||
segment_mode=segment_mode,
|
||||
delete_after=opts.get("delete_after_upload", False),
|
||||
ntfy_enabled=opts.get("ntfy_enabled", False),
|
||||
save_url_fn=lambda url: self._update_recording(rec_id, plik_url=url),
|
||||
)
|
||||
|
||||
def _cleanup(self, rec_id: int):
|
||||
|
||||
+39
-18
@@ -54,6 +54,23 @@ def _in_time_window(now_time: str, start: str, stop: str) -> bool:
|
||||
return now_min >= start_min or now_min < stop_min
|
||||
|
||||
|
||||
def _apply_buffers(job: dict) -> dict:
|
||||
eff = dict(job)
|
||||
pre = job.get("pre_buffer", 0) or 0
|
||||
post = job.get("post_buffer", 0) or 0
|
||||
if pre > 0 and eff.get("schedule_start"):
|
||||
m = _time_to_minutes(eff["schedule_start"]) - pre
|
||||
if m < 0:
|
||||
m += 1440
|
||||
eff["schedule_start"] = f"{m // 60:02d}:{m % 60:02d}"
|
||||
if post > 0 and eff.get("schedule_stop"):
|
||||
m = _time_to_minutes(eff["schedule_stop"]) + post
|
||||
if m >= 1440:
|
||||
m -= 1440
|
||||
eff["schedule_stop"] = f"{m // 60:02d}:{m % 60:02d}"
|
||||
return eff
|
||||
|
||||
|
||||
class StreamScheduler:
|
||||
def __init__(self, recorder):
|
||||
self.recorder = recorder
|
||||
@@ -76,6 +93,12 @@ class StreamScheduler:
|
||||
today = job.schedule_date or today
|
||||
return f"{job.id}|{job.schedule_once}|{today}|{job.schedule_start}"
|
||||
|
||||
def _original_occ_key(self, job: dict, now: datetime.datetime | None = None) -> str:
|
||||
now = now or datetime.datetime.now()
|
||||
today_date = now.strftime("%Y-%m-%d")
|
||||
date = today_date if not job["schedule_once"] else job["schedule_date"]
|
||||
return f"{job['id']}|{job['schedule_once']}|{date}|{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()
|
||||
@@ -140,15 +163,19 @@ class StreamScheduler:
|
||||
"schedule_start": j.schedule_start,
|
||||
"schedule_stop": j.schedule_stop,
|
||||
"schedule_end_date": j.schedule_end_date,
|
||||
"pre_buffer": j.pre_buffer or 0,
|
||||
"post_buffer": j.post_buffer or 0,
|
||||
}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
state = self._schedule_state(job)
|
||||
occ_key = self._original_occ_key(job)
|
||||
eff_job = _apply_buffers(job)
|
||||
state = self._schedule_state(eff_job)
|
||||
if not state["in_window"]:
|
||||
return False
|
||||
|
||||
self._started_keys.add(state["occ_key"])
|
||||
self._started_keys.add(occ_key)
|
||||
return True
|
||||
|
||||
def _occurrence_start_utc(self, job: dict, now: datetime.datetime) -> datetime.datetime | None:
|
||||
@@ -219,11 +246,8 @@ class StreamScheduler:
|
||||
"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,
|
||||
"pre_buffer": j.pre_buffer or 0, "post_buffer": j.post_buffer or 0,
|
||||
"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,
|
||||
@@ -235,9 +259,10 @@ class StreamScheduler:
|
||||
if not job["schedule_start"]:
|
||||
continue
|
||||
|
||||
state = self._schedule_state(job, now)
|
||||
occ_key = self._original_occ_key(job, now)
|
||||
eff_job = _apply_buffers(job)
|
||||
state = self._schedule_state(eff_job, now)
|
||||
in_window = state["in_window"]
|
||||
occ_key = state["occ_key"]
|
||||
|
||||
if in_window:
|
||||
if (
|
||||
@@ -246,16 +271,16 @@ class StreamScheduler:
|
||||
and not self._has_recording_for_occurrence(job, now)
|
||||
):
|
||||
duration = job["max_duration"]
|
||||
if not duration and job["schedule_stop"]:
|
||||
if not duration and eff_job["schedule_stop"]:
|
||||
if state["multi_day"]:
|
||||
start_dt = datetime.datetime.strptime(
|
||||
f"{job['schedule_date']} {job['schedule_start']}", "%Y-%m-%d %H:%M")
|
||||
f"{job['schedule_date']} {eff_job['schedule_start']}", "%Y-%m-%d %H:%M")
|
||||
end_dt = datetime.datetime.strptime(
|
||||
f"{job['schedule_end_date']} {job['schedule_stop']}", "%Y-%m-%d %H:%M")
|
||||
f"{job['schedule_end_date']} {eff_job['schedule_stop']}", "%Y-%m-%d %H:%M")
|
||||
duration = max(0, int((end_dt - start_dt).total_seconds()))
|
||||
else:
|
||||
start_min = _time_to_minutes(job["schedule_start"])
|
||||
stop_min = _time_to_minutes(job["schedule_stop"])
|
||||
start_min = _time_to_minutes(eff_job["schedule_start"])
|
||||
stop_min = _time_to_minutes(eff_job["schedule_stop"])
|
||||
if stop_min > start_min:
|
||||
duration = (stop_min - start_min) * 60
|
||||
else:
|
||||
@@ -272,10 +297,6 @@ class StreamScheduler:
|
||||
segment_duration=job["segment_duration"],
|
||||
is_scheduled=True,
|
||||
description=job.get("description", ""),
|
||||
metadata_monitor_enabled=job.get("metadata_monitor_enabled", False),
|
||||
metadata_pattern=job.get("metadata_pattern", ""),
|
||||
metadata_grace_period=job.get("metadata_grace_period") or 300,
|
||||
metadata_poll_interval=job.get("metadata_poll_interval") or 30,
|
||||
ntfy_enabled=job.get("ntfy_enabled", False),
|
||||
plik_enabled=job.get("plik_enabled", False),
|
||||
delete_after_upload=job.get("delete_after_upload", False),
|
||||
@@ -285,7 +306,7 @@ class StreamScheduler:
|
||||
if (
|
||||
not state["multi_day"]
|
||||
and not state["is_cross_midnight"]
|
||||
and job["schedule_stop"]
|
||||
and eff_job["schedule_stop"]
|
||||
and self._is_job_recording(job["id"])
|
||||
):
|
||||
rec_id = self._get_active_scheduled_recording_for_job(job["id"])
|
||||
|
||||
@@ -38,5 +38,10 @@ plik:
|
||||
api_key: ""
|
||||
ttl: "30d"
|
||||
|
||||
# Zeitplan-Puffer (Minuten) – Standardwerte für neue Jobs
|
||||
schedule:
|
||||
default_pre_buffer: 5
|
||||
default_post_buffer: 5
|
||||
|
||||
logging:
|
||||
level: INFO
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
+287
-64
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Stream-Recorder</title>
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
@@ -54,7 +55,15 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
header h1 .rec-dot {
|
||||
header h1 .header-logo {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 50%;
|
||||
transition: filter 0.3s;
|
||||
}
|
||||
|
||||
|
||||
.rec-dot {
|
||||
display: inline-block;
|
||||
width: 10px; height: 10px;
|
||||
background: var(--recording);
|
||||
border-radius: 50%;
|
||||
@@ -307,13 +316,49 @@
|
||||
.remaining.low { color: var(--warning); }
|
||||
.remaining.critical { color: var(--error); }
|
||||
|
||||
/* Burger menu */
|
||||
.burger {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.burger:hover { background: var(--bg-card); }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.app { padding: 10px; }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.tabs { flex-wrap: wrap; }
|
||||
.tab { padding: 6px 12px; font-size: 0.8rem; }
|
||||
header { flex-direction: column; gap: 12px; align-items: flex-start; }
|
||||
.burger { display: block; }
|
||||
.tabs {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0 0 var(--radius) var(--radius);
|
||||
padding: 8px;
|
||||
z-index: 90;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
.tabs.open { display: flex; }
|
||||
.tab { padding: 10px 16px; font-size: 0.9rem; text-align: left; border-radius: 6px; }
|
||||
header {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
}
|
||||
header h1 { font-size: 1.1rem; }
|
||||
.card { padding: 12px; }
|
||||
.card-header { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||
@@ -340,6 +385,29 @@
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.card-recent { cursor: pointer; }
|
||||
.card-recent:hover { border-color: var(--accent); }
|
||||
.recent-meta { display: flex; gap: 0; font-size: 0.8rem; color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
||||
.recent-meta .rm-dur { min-width: 100px; text-align: right; }
|
||||
.recent-meta .rm-size { min-width: 90px; text-align: right; }
|
||||
.recent-meta .rm-date { min-width: 150px; text-align: right; }
|
||||
@media (max-width: 640px) {
|
||||
.recent-meta { flex-wrap: wrap; gap: 4px; }
|
||||
.recent-meta .rm-dur, .recent-meta .rm-size, .recent-meta .rm-date { min-width: auto; text-align: left; }
|
||||
}
|
||||
|
||||
.rec-detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px 24px; }
|
||||
.rec-detail-item { display: flex; flex-direction: column; gap: 2px; }
|
||||
.rec-detail-item.full { grid-column: 1 / -1; }
|
||||
.rec-detail-label { font-size: 0.7rem; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.rec-detail-value { font-size: 0.9rem; color: var(--text); word-break: break-all; }
|
||||
.rec-detail-value a { color: var(--accent); text-decoration: none; }
|
||||
.rec-detail-value a:hover { text-decoration: underline; }
|
||||
.rec-plik-link { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; background: rgba(108,123,255,0.1); border: 1px solid var(--accent); border-radius: 6px; color: var(--accent); font-weight: 500; font-size: 0.85rem; text-decoration: none; transition: background 0.15s; }
|
||||
.rec-plik-link:hover { background: rgba(108,123,255,0.2); text-decoration: none; }
|
||||
.rec-detail-divider { grid-column: 1 / -1; border: none; border-top: 1px solid var(--border); margin: 4px 0; }
|
||||
@media (max-width: 640px) { .rec-detail-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.site-footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
@@ -366,8 +434,9 @@
|
||||
<body>
|
||||
<div class="app">
|
||||
<header>
|
||||
<h1><span class="rec-dot" id="recDot"></span> Stream-Recorder</h1>
|
||||
<div class="tabs">
|
||||
<h1><img src="/logo.png" alt="Logo" class="header-logo" id="headerLogo"> Stream-Recorder</h1>
|
||||
<button class="burger" id="burgerBtn" onclick="toggleBurger()" aria-label="Menü">☰</button>
|
||||
<div class="tabs" id="navTabs">
|
||||
<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>
|
||||
@@ -487,7 +556,7 @@
|
||||
<option value="">URL manuell eingeben</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<div class="form-group full" id="jobUrlGroup">
|
||||
<label>Stream-URL *</label>
|
||||
<input type="text" id="job-url" placeholder="rtmp://... oder https://...m3u8">
|
||||
</div>
|
||||
@@ -567,6 +636,23 @@
|
||||
<input type="time" id="job-schedule-stop" oninput="updateDurationField()">
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:8px; padding:8px 12px; background:var(--bg-input); border:1px solid var(--border); border-radius:6px">
|
||||
<div style="font-size:0.8rem; color:var(--text-dim); margin-bottom:6px">Zeitpuffer</div>
|
||||
<div style="display:flex; gap:16px; flex-wrap:wrap">
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" id="job-pre-buffer-enabled" onchange="togglePreBuffer()">
|
||||
<label for="job-pre-buffer-enabled" style="margin:0">Vorlaufzeit</label>
|
||||
<input type="number" id="job-pre-buffer" min="1" max="120" style="width:60px; display:none" oninput="updateDurationField()">
|
||||
<span id="job-pre-buffer-unit" style="font-size:0.8rem; color:var(--text-dim); display:none">Min.</span>
|
||||
</div>
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" id="job-post-buffer-enabled" onchange="togglePostBuffer()">
|
||||
<label for="job-post-buffer-enabled" style="margin:0">Nachlaufzeit</label>
|
||||
<input type="number" id="job-post-buffer" min="1" max="120" style="width:60px; display:none" oninput="updateDurationField()">
|
||||
<span id="job-post-buffer-unit" style="font-size:0.8rem; color:var(--text-dim); display:none">Min.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -586,28 +672,7 @@
|
||||
<div id="plikFields" style="display:none; margin-top:8px; margin-left:24px">
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" id="job-delete-after">
|
||||
<label for="job-delete-after" style="margin:0">Nach Upload löschen</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="job-section" style="margin-top:12px; border-top:1px solid var(--border); padding-top:12px">
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" id="job-metadata-enabled" onchange="toggleMetadata()">
|
||||
<label for="job-metadata-enabled" style="margin:0">Euer-Radio Metadata-Monitor</label>
|
||||
</div>
|
||||
<div id="metadataFields" style="display:none; margin-top:8px" class="form-grid">
|
||||
<div class="form-group full">
|
||||
<label>Show-Pattern (z.B. Sendungsname)</label>
|
||||
<input type="text" id="job-metadata-pattern" placeholder="Donnerwolke">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Karenzzeit (Min.)</label>
|
||||
<input type="number" id="job-metadata-grace" value="5" min="1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Poll-Intervall (Sek.)</label>
|
||||
<input type="number" id="job-metadata-poll" value="30" min="10">
|
||||
<label for="job-delete-after" style="margin:0">File nach Upload lokal löschen</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -683,6 +748,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recording Detail Modal -->
|
||||
<div class="modal-overlay" id="recDetailModal">
|
||||
<div class="modal" style="max-width:560px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
||||
<div class="modal-title" id="recDetailTitle" style="margin:0"></div>
|
||||
<div id="recDetailBadges"></div>
|
||||
</div>
|
||||
<div class="rec-detail-grid" id="recDetailBody"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" onclick="closeModal('recDetailModal')">Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API = '';
|
||||
let stationsCache = [];
|
||||
@@ -699,8 +778,14 @@ async function api(path, opts = {}) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Burger menu
|
||||
function toggleBurger() {
|
||||
document.getElementById('navTabs').classList.toggle('open');
|
||||
}
|
||||
|
||||
// Tabs
|
||||
function switchTab(name) {
|
||||
document.getElementById('navTabs').classList.remove('open');
|
||||
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');
|
||||
@@ -727,10 +812,6 @@ async function loadDashboard() {
|
||||
const active = await api('/api/recordings?active_only=true');
|
||||
const recent = await api('/api/recordings?limit=20');
|
||||
|
||||
const dot = document.getElementById('recDot');
|
||||
dot.style.background = active.length > 0 ? 'var(--recording)' : 'var(--text-dim)';
|
||||
dot.style.animation = active.length > 0 ? 'pulse 2s ease-in-out infinite' : 'none';
|
||||
|
||||
renderActive(active);
|
||||
renderRecent(recent.filter(r => r.status !== 'running' && r.status !== 'starting'));
|
||||
} catch (e) {
|
||||
@@ -752,7 +833,7 @@ function renderActive(recs) {
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
<span style="color:var(--recording)">●</span>
|
||||
<span class="rec-dot"></span>
|
||||
${esc(r.job_name)}
|
||||
<span class="badge badge-${r.status}">${r.status}</span>
|
||||
${r.is_scheduled ? '<span class="badge badge-scheduled">geplant</span>' : ''}
|
||||
@@ -781,21 +862,98 @@ function renderRecent(recs) {
|
||||
el.innerHTML = '<div class="empty" style="padding:24px"><span style="color:var(--text-dim)">Noch keine abgeschlossenen Aufnahmen</span></div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = recs.slice(0, 10).map(r => `
|
||||
<div class="card" style="padding:12px">
|
||||
el.innerHTML = recs.slice(0, 10).map(r => {
|
||||
const started = r.started_at ? new Date(r.started_at).toLocaleString('de-DE') : '';
|
||||
return `
|
||||
<div class="card card-recent" style="padding:12px" onclick="openRecDetail(${r.id})">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<span style="font-weight:500">${esc(r.job_name)}</span>
|
||||
<span class="badge badge-${r.status}" style="margin-left:8px">${r.status}</span>
|
||||
${r.plik_url ? '<span class="badge badge-completed" style="margin-left:4px">📤 Plik</span>' : ''}
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="meta-item">🕓 ${r.elapsed_display}</span>
|
||||
<span class="meta-item">💾 ${r.total_size_display}</span>
|
||||
<span class="meta-item">${r.started_at ? new Date(r.started_at).toLocaleString('de-DE') : ''}</span>
|
||||
<div class="recent-meta">
|
||||
<span class="rm-dur">🕓 ${r.elapsed_display}</span>
|
||||
<span class="rm-size">💾 ${r.total_size_display}</span>
|
||||
<span class="rm-date">${started}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function openRecDetail(recId) {
|
||||
try {
|
||||
const r = await api('/api/recordings/' + recId);
|
||||
const started = r.started_at ? new Date(r.started_at).toLocaleString('de-DE') : '—';
|
||||
const stopped = r.stopped_at ? new Date(r.stopped_at).toLocaleString('de-DE') : '—';
|
||||
const fileName = r.output_file ? r.output_file.split('/').pop() : null;
|
||||
|
||||
document.getElementById('recDetailTitle').textContent = r.job_name;
|
||||
document.getElementById('recDetailBadges').innerHTML =
|
||||
`<span class="badge badge-${r.status}">${r.status}</span>` +
|
||||
(r.plik_url ? ' <span class="badge badge-completed">📤 Plik</span>' : '');
|
||||
|
||||
let html = '';
|
||||
|
||||
if (r.plik_url) {
|
||||
html += `<div class="rec-detail-item full">
|
||||
<span class="rec-detail-label">Plik-Download</span>
|
||||
<span class="rec-detail-value"><a class="rec-plik-link" href="${esc(r.plik_url)}" target="_blank" rel="noopener">🔗 Im Browser öffnen</a></span>
|
||||
</div>
|
||||
<hr class="rec-detail-divider">`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="rec-detail-item">
|
||||
<span class="rec-detail-label">Gestartet</span>
|
||||
<span class="rec-detail-value">${started}</span>
|
||||
</div>
|
||||
<div class="rec-detail-item">
|
||||
<span class="rec-detail-label">Beendet</span>
|
||||
<span class="rec-detail-value">${stopped}</span>
|
||||
</div>
|
||||
<div class="rec-detail-item">
|
||||
<span class="rec-detail-label">Dauer</span>
|
||||
<span class="rec-detail-value">${r.elapsed_display}</span>
|
||||
</div>
|
||||
<div class="rec-detail-item">
|
||||
<span class="rec-detail-label">Dateigröße</span>
|
||||
<span class="rec-detail-value">${r.total_size_display}</span>
|
||||
</div>`;
|
||||
|
||||
if (r.error_message) {
|
||||
html += `<div class="rec-detail-item full">
|
||||
<span class="rec-detail-label">Fehler</span>
|
||||
<span class="rec-detail-value" style="color:var(--error)">${esc(r.error_message)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
html += '<hr class="rec-detail-divider">';
|
||||
|
||||
if (fileName) {
|
||||
html += `<div class="rec-detail-item full">
|
||||
<span class="rec-detail-label">Datei</span>
|
||||
<span class="rec-detail-value">${esc(fileName)}</span>
|
||||
</div>`;
|
||||
}
|
||||
if (r.output_dir) {
|
||||
html += `<div class="rec-detail-item full">
|
||||
<span class="rec-detail-label">Verzeichnis</span>
|
||||
<span class="rec-detail-value">${esc(r.output_dir)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
html += `<div class="rec-detail-item full">
|
||||
<span class="rec-detail-label">Stream-URL</span>
|
||||
<span class="rec-detail-value">${esc(r.stream_url)}</span>
|
||||
</div>`;
|
||||
|
||||
document.getElementById('recDetailBody').innerHTML = html;
|
||||
openModal('recDetailModal');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Stations
|
||||
@@ -908,6 +1066,7 @@ function applyStationToQuickRecord() {
|
||||
function applyStationToJob() {
|
||||
const id = document.getElementById('job-station').value;
|
||||
const station = stationsCache.find(s => String(s.id) === id);
|
||||
document.getElementById('jobUrlGroup').style.display = station ? 'none' : '';
|
||||
if (!station) return;
|
||||
document.getElementById('job-url').value = station.stream_url;
|
||||
document.getElementById('job-type').value = station.stream_type || 'auto';
|
||||
@@ -945,13 +1104,12 @@ async function loadJobs() {
|
||||
${j.description ? `<div class="meta" style="margin-bottom:4px"><span class="meta-item" style="color:var(--text)">${esc(j.description)}</span></div>` : ''}
|
||||
<div class="meta">
|
||||
<span class="meta-item">${esc(j.stream_type || 'auto')}</span>
|
||||
<span class="meta-item">${esc(j.stream_url)}</span>
|
||||
<span class="meta-item">${j.station_name ? '📻 ' + esc(j.station_name) : esc(j.stream_url)}</span>
|
||||
${j.max_duration ? `<span class="meta-item">🕓 ${Math.round(j.max_duration/60)} Min</span>` : ''}
|
||||
${j.segment_duration ? `<span class="meta-item">📄 alle ${Math.round(j.segment_duration/60)} Min</span>` : ''}
|
||||
${j.schedule_enabled ? `<span class="meta-item">📅 ${j.schedule_once ? j.schedule_date : j.schedule_days} ${j.schedule_start}${j.schedule_stop ? ' - '+j.schedule_stop : ''}</span>` : ''}
|
||||
${j.schedule_enabled ? `<span class="meta-item">📅 ${j.schedule_once ? j.schedule_date : j.schedule_days} ${j.schedule_start}${j.schedule_stop ? ' - '+j.schedule_stop : ''}${j.pre_buffer ? ' (−'+j.pre_buffer+'min)' : ''}${j.post_buffer ? ' (+'+j.post_buffer+'min)' : ''}</span>` : ''}
|
||||
${j.ntfy_enabled ? `<span class="meta-item">🔔 NTFY</span>` : ''}
|
||||
${j.plik_enabled ? `<span class="meta-item">📤 Plik${j.delete_after_upload ? ' (löschen)' : ''}</span>` : ''}
|
||||
${j.metadata_monitor_enabled ? `<span class="meta-item">📡 ${esc(j.metadata_pattern)}</span>` : ''}
|
||||
${j.plik_enabled ? `<span class="meta-item">📤 Plik${j.delete_after_upload ? ' (danach löschen)' : ''}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
@@ -964,7 +1122,8 @@ 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-station').value = job && job.station_id ? job.station_id : '';
|
||||
document.getElementById('jobUrlGroup').style.display = (job && job.station_id) ? 'none' : '';
|
||||
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';
|
||||
@@ -983,14 +1142,15 @@ function openJobModal(job = null) {
|
||||
document.getElementById('job-ntfy-enabled').checked = job ? job.ntfy_enabled : false;
|
||||
document.getElementById('job-plik-enabled').checked = job ? job.plik_enabled : false;
|
||||
document.getElementById('job-delete-after').checked = job ? job.delete_after_upload : false;
|
||||
document.getElementById('job-metadata-enabled').checked = job ? job.metadata_monitor_enabled : false;
|
||||
document.getElementById('job-metadata-pattern').value = job ? job.metadata_pattern || '' : '';
|
||||
document.getElementById('job-metadata-grace').value = job ? Math.round((job.metadata_grace_period || 300) / 60) : 5;
|
||||
document.getElementById('job-metadata-poll').value = job ? job.metadata_poll_interval || 30 : 30;
|
||||
document.getElementById('job-pre-buffer-enabled').checked = job ? (job.pre_buffer || 0) > 0 : false;
|
||||
document.getElementById('job-pre-buffer').value = job && job.pre_buffer ? job.pre_buffer : '';
|
||||
document.getElementById('job-post-buffer-enabled').checked = job ? (job.post_buffer || 0) > 0 : false;
|
||||
document.getElementById('job-post-buffer').value = job && job.post_buffer ? job.post_buffer : '';
|
||||
toggleSchedule();
|
||||
toggleScheduleOnce();
|
||||
togglePreBuffer();
|
||||
togglePostBuffer();
|
||||
togglePlik();
|
||||
toggleMetadata();
|
||||
openModal('jobModal');
|
||||
}
|
||||
|
||||
@@ -1018,14 +1178,13 @@ async function saveJob() {
|
||||
schedule_start: document.getElementById('job-schedule-start').value,
|
||||
schedule_stop: document.getElementById('job-schedule-stop').value,
|
||||
schedule_end_date: document.getElementById('job-schedule-end-date').value,
|
||||
pre_buffer: document.getElementById('job-pre-buffer-enabled').checked ? parseInt(document.getElementById('job-pre-buffer').value || '0') : 0,
|
||||
post_buffer: document.getElementById('job-post-buffer-enabled').checked ? parseInt(document.getElementById('job-post-buffer').value || '0') : 0,
|
||||
description: document.getElementById('job-description').value,
|
||||
ntfy_enabled: document.getElementById('job-ntfy-enabled').checked,
|
||||
plik_enabled: document.getElementById('job-plik-enabled').checked,
|
||||
delete_after_upload: document.getElementById('job-delete-after').checked,
|
||||
metadata_monitor_enabled: document.getElementById('job-metadata-enabled').checked,
|
||||
metadata_pattern: document.getElementById('job-metadata-pattern').value,
|
||||
metadata_grace_period: parseInt(document.getElementById('job-metadata-grace').value || '5') * 60,
|
||||
metadata_poll_interval: parseInt(document.getElementById('job-metadata-poll').value || '30'),
|
||||
station_id: document.getElementById('job-station').value ? parseInt(document.getElementById('job-station').value) : null,
|
||||
};
|
||||
|
||||
if (!data.name || !data.stream_url) {
|
||||
@@ -1081,11 +1240,14 @@ function updateDurationField() {
|
||||
document.getElementById('durationAutoGroup').style.display = hasScheduleStop ? '' : 'none';
|
||||
|
||||
if (hasScheduleStop) {
|
||||
const preBuf = document.getElementById('job-pre-buffer-enabled').checked ? parseInt(document.getElementById('job-pre-buffer').value || '0') : 0;
|
||||
const postBuf = document.getElementById('job-post-buffer-enabled').checked ? parseInt(document.getElementById('job-post-buffer').value || '0') : 0;
|
||||
const bufTotal = preBuf + postBuf;
|
||||
let text;
|
||||
if (once && startDate && endDate && endDate !== startDate) {
|
||||
const startDt = new Date(`${startDate}T${start}`);
|
||||
const endDt = new Date(`${endDate}T${stop}`);
|
||||
let diffMin = Math.round((endDt - startDt) / 60000);
|
||||
let diffMin = Math.round((endDt - startDt) / 60000) + bufTotal;
|
||||
if (diffMin <= 0) { text = 'Enddatum muss nach Startdatum liegen'; }
|
||||
else {
|
||||
const d = Math.floor(diffMin / 1440);
|
||||
@@ -1096,15 +1258,18 @@ function updateDurationField() {
|
||||
if (h > 0) parts.push(`${h} Std.`);
|
||||
if (m > 0) parts.push(`${m} Min.`);
|
||||
text = `${parts.join(' ')} (mehrtägig, aus Zeitplan)`;
|
||||
if (bufTotal > 0) text += ` inkl. ${bufTotal} Min. Puffer`;
|
||||
}
|
||||
} else {
|
||||
const [sh, sm] = start.split(':').map(Number);
|
||||
const [eh, em] = stop.split(':').map(Number);
|
||||
let diff = (eh * 60 + em) - (sh * 60 + sm);
|
||||
if (diff <= 0) diff += 1440;
|
||||
diff += bufTotal;
|
||||
const h = Math.floor(diff / 60);
|
||||
const m = diff % 60;
|
||||
text = (h > 0 ? `${h} Std. ${m > 0 ? m + ' Min.' : ''}` : `${m} Min.`) + ' (automatisch aus Zeitplan)';
|
||||
text = (h > 0 ? `${h} Std. ${m > 0 ? m + ' Min.' : ''}` : `${m} Min.`) + ' (aus Zeitplan)';
|
||||
if (bufTotal > 0) text += ` inkl. ${bufTotal} Min. Puffer`;
|
||||
}
|
||||
document.getElementById('durationAutoText').textContent = text;
|
||||
}
|
||||
@@ -1118,16 +1283,31 @@ function toggleScheduleOnce() {
|
||||
updateDurationField();
|
||||
}
|
||||
|
||||
function togglePreBuffer() {
|
||||
const on = document.getElementById('job-pre-buffer-enabled').checked;
|
||||
document.getElementById('job-pre-buffer').style.display = on ? '' : 'none';
|
||||
document.getElementById('job-pre-buffer-unit').style.display = on ? '' : 'none';
|
||||
if (on && !document.getElementById('job-pre-buffer').value) {
|
||||
document.getElementById('job-pre-buffer').value = window._defaultPreBuffer || 5;
|
||||
}
|
||||
updateDurationField();
|
||||
}
|
||||
|
||||
function togglePostBuffer() {
|
||||
const on = document.getElementById('job-post-buffer-enabled').checked;
|
||||
document.getElementById('job-post-buffer').style.display = on ? '' : 'none';
|
||||
document.getElementById('job-post-buffer-unit').style.display = on ? '' : 'none';
|
||||
if (on && !document.getElementById('job-post-buffer').value) {
|
||||
document.getElementById('job-post-buffer').value = window._defaultPostBuffer || 5;
|
||||
}
|
||||
updateDurationField();
|
||||
}
|
||||
|
||||
function togglePlik() {
|
||||
document.getElementById('plikFields').style.display =
|
||||
document.getElementById('job-plik-enabled').checked ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function toggleMetadata() {
|
||||
document.getElementById('metadataFields').style.display =
|
||||
document.getElementById('job-metadata-enabled').checked ? 'grid' : 'none';
|
||||
}
|
||||
|
||||
// Quick Record
|
||||
function openQuickRecord() {
|
||||
loadStationOptions();
|
||||
@@ -1250,6 +1430,19 @@ async function loadSettings() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<h3>Zeitplan-Puffer (Standardwerte)</h3>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>Standard-Vorlaufzeit (Min.)</label>
|
||||
<input type="number" id="cfg-pre-buffer" min="1" max="120" value="${cfg.schedule?.default_pre_buffer ?? 5}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Standard-Nachlaufzeit (Min.)</label>
|
||||
<input type="number" id="cfg-post-buffer" min="1" max="120" value="${cfg.schedule?.default_post_buffer ?? 5}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<h3>NTFY-Benachrichtigungen</h3>
|
||||
<div class="form-grid">
|
||||
@@ -1298,12 +1491,19 @@ async function saveSettings() {
|
||||
};
|
||||
if (passVal && passVal !== '***') authData.password = passVal;
|
||||
|
||||
window._defaultPreBuffer = parseInt(document.getElementById('cfg-pre-buffer').value) || 5;
|
||||
window._defaultPostBuffer = parseInt(document.getElementById('cfg-post-buffer').value) || 5;
|
||||
|
||||
const data = {
|
||||
recording: {
|
||||
download_path: document.getElementById('cfg-download-path').value,
|
||||
max_retries: parseInt(document.getElementById('cfg-max-retries').value) || 5,
|
||||
retry_delay: parseInt(document.getElementById('cfg-retry-delay').value) || 5,
|
||||
},
|
||||
schedule: {
|
||||
default_pre_buffer: parseInt(document.getElementById('cfg-pre-buffer').value) || 5,
|
||||
default_post_buffer: parseInt(document.getElementById('cfg-post-buffer').value) || 5,
|
||||
},
|
||||
auth: authData,
|
||||
ntfy: {
|
||||
url: document.getElementById('cfg-ntfy-url').value,
|
||||
@@ -1337,13 +1537,36 @@ document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
||||
});
|
||||
});
|
||||
|
||||
// Close burger menu on outside click
|
||||
document.addEventListener('click', e => {
|
||||
const tabs = document.getElementById('navTabs');
|
||||
const burger = document.getElementById('burgerBtn');
|
||||
if (tabs.classList.contains('open') && !tabs.contains(e.target) && e.target !== burger) {
|
||||
tabs.classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
// Load schedule defaults from config
|
||||
async function loadDefaults() {
|
||||
try {
|
||||
const cfg = await api('/api/config');
|
||||
window._defaultPreBuffer = cfg.schedule?.default_pre_buffer ?? 5;
|
||||
window._defaultPostBuffer = cfg.schedule?.default_post_buffer ?? 5;
|
||||
} catch (e) { /* defaults stay at 5 */ }
|
||||
}
|
||||
|
||||
// Auto-refresh
|
||||
loadStationOptions();
|
||||
loadDefaults();
|
||||
loadDashboard();
|
||||
setInterval(loadDashboard, 5000);
|
||||
|
||||
api('/api/health').then(h => {
|
||||
document.getElementById('footerVersion').textContent = 'Stream-Recorder v' + h.version;
|
||||
}).catch(() => {});
|
||||
</script>
|
||||
<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>
|
||||
<span id="footerVersion">Stream-Recorder</span> © <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>
|
||||
</html>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 135 KiB |
Reference in New Issue
Block a user