Release: v3.1.0
@@ -3,7 +3,7 @@ name: Build Dev Docker Image
|
|||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches: [main, dev]
|
branches: [dev]
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- 'README.md'
|
- 'README.md'
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
|
|
||||||
COPY bin/backend/ /app/
|
COPY bin/backend/ /app/
|
||||||
COPY bin/frontend/ /app/frontend/
|
COPY bin/frontend/ /app/frontend/
|
||||||
COPY config/config.yml.dist /app/config.yml.dist
|
COPY bin/config.yml.dist /app/config.yml.dist
|
||||||
|
|
||||||
RUN mkdir -p /app/data/recordings
|
RUN mkdir -p /app/data/recordings
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,30 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2023 scriptos
|
---
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
Copyright © Patrick Asmus (scriptos)
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
Website: https://www.patrick-asmus.de
|
||||||
|
Blog: https://www.cleveradmin.de
|
||||||
|
Email: support@techniverse.net
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
</h6>
|
</h6>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Funktionen
|
## Funktionen
|
||||||
|
|
||||||
- **Web-Interface** — Jobs planen, Aufnahmen steuern und verlängern im Browser
|
- **Web-Interface** — Jobs planen, Aufnahmen steuern und verlängern im Browser
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ from models import init_db, get_session, Job, Station, Recording as RecordingMod
|
|||||||
from recorder import RecordingManager, format_elapsed
|
from recorder import RecordingManager, format_elapsed
|
||||||
from scheduler import StreamScheduler
|
from scheduler import StreamScheduler
|
||||||
|
|
||||||
|
APP_VERSION = "3.0.0"
|
||||||
|
|
||||||
DATA_DIR = os.environ.get("DATA_DIR", "/app/data")
|
DATA_DIR = os.environ.get("DATA_DIR", "/app/data")
|
||||||
STATIC_DIR = os.environ.get("STATIC_DIR", "/app/frontend/static")
|
STATIC_DIR = os.environ.get("STATIC_DIR", "/app/frontend/static")
|
||||||
|
|
||||||
@@ -37,7 +39,7 @@ async def lifespan(app):
|
|||||||
scheduler.stop()
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -78,7 +80,7 @@ async def auth_middleware(request: Request, call_next):
|
|||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok", "version": "3.0.0"}
|
return {"status": "ok", "version": APP_VERSION}
|
||||||
|
|
||||||
|
|
||||||
# --- Jobs CRUD ---
|
# --- Jobs CRUD ---
|
||||||
@@ -198,14 +200,13 @@ class JobCreate(BaseModel):
|
|||||||
schedule_start: str = ""
|
schedule_start: str = ""
|
||||||
schedule_stop: str = ""
|
schedule_stop: str = ""
|
||||||
schedule_end_date: str = ""
|
schedule_end_date: str = ""
|
||||||
|
pre_buffer: int = 0
|
||||||
|
post_buffer: int = 0
|
||||||
description: str = ""
|
description: str = ""
|
||||||
ntfy_enabled: bool = False
|
ntfy_enabled: bool = False
|
||||||
plik_enabled: bool = False
|
plik_enabled: bool = False
|
||||||
delete_after_upload: bool = False
|
delete_after_upload: bool = False
|
||||||
metadata_monitor_enabled: bool = False
|
station_id: Optional[int] = None
|
||||||
metadata_pattern: str = ""
|
|
||||||
metadata_grace_period: int = 300
|
|
||||||
metadata_poll_interval: int = 30
|
|
||||||
|
|
||||||
|
|
||||||
class JobUpdate(JobCreate):
|
class JobUpdate(JobCreate):
|
||||||
@@ -217,6 +218,13 @@ async def list_jobs():
|
|||||||
session = get_session()
|
session = get_session()
|
||||||
try:
|
try:
|
||||||
jobs = session.query(Job).order_by(Job.id).all()
|
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 = []
|
result = []
|
||||||
for j in jobs:
|
for j in jobs:
|
||||||
is_recording = session.query(
|
is_recording = session.query(
|
||||||
@@ -239,14 +247,14 @@ async def list_jobs():
|
|||||||
"schedule_start": j.schedule_start,
|
"schedule_start": j.schedule_start,
|
||||||
"schedule_stop": j.schedule_stop,
|
"schedule_stop": j.schedule_stop,
|
||||||
"schedule_end_date": j.schedule_end_date,
|
"schedule_end_date": j.schedule_end_date,
|
||||||
|
"pre_buffer": j.pre_buffer or 0,
|
||||||
|
"post_buffer": j.post_buffer or 0,
|
||||||
"description": j.description,
|
"description": j.description,
|
||||||
"ntfy_enabled": j.ntfy_enabled,
|
"ntfy_enabled": j.ntfy_enabled,
|
||||||
"plik_enabled": j.plik_enabled,
|
"plik_enabled": j.plik_enabled,
|
||||||
"delete_after_upload": j.delete_after_upload,
|
"delete_after_upload": j.delete_after_upload,
|
||||||
"metadata_monitor_enabled": j.metadata_monitor_enabled,
|
"station_id": j.station_id,
|
||||||
"metadata_pattern": j.metadata_pattern,
|
"station_name": station_names.get(j.station_id) if j.station_id else None,
|
||||||
"metadata_grace_period": j.metadata_grace_period,
|
|
||||||
"metadata_poll_interval": j.metadata_poll_interval,
|
|
||||||
"is_recording": is_recording,
|
"is_recording": is_recording,
|
||||||
"created_at": j.created_at.isoformat() if j.created_at else None,
|
"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_enabled": job.schedule_enabled, "schedule_once": job.schedule_once,
|
||||||
"schedule_date": job.schedule_date, "schedule_days": job.schedule_days,
|
"schedule_date": job.schedule_date, "schedule_days": job.schedule_days,
|
||||||
"schedule_start": job.schedule_start, "schedule_stop": job.schedule_stop,
|
"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,
|
"ntfy_enabled": job.ntfy_enabled,
|
||||||
"plik_enabled": job.plik_enabled, "delete_after_upload": job.delete_after_upload,
|
"plik_enabled": job.plik_enabled, "delete_after_upload": job.delete_after_upload,
|
||||||
"metadata_monitor_enabled": job.metadata_monitor_enabled,
|
"station_id": job.station_id,
|
||||||
"metadata_pattern": job.metadata_pattern,
|
|
||||||
"metadata_grace_period": job.metadata_grace_period,
|
|
||||||
"metadata_poll_interval": job.metadata_poll_interval,
|
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
@@ -354,10 +361,6 @@ async def start_job_recording(job_id: int):
|
|||||||
"max_duration": duration, "extra_ffmpeg_args": job.extra_ffmpeg_args,
|
"max_duration": duration, "extra_ffmpeg_args": job.extra_ffmpeg_args,
|
||||||
"segment_duration": job.segment_duration,
|
"segment_duration": job.segment_duration,
|
||||||
"description": job.description,
|
"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,
|
"ntfy_enabled": job.ntfy_enabled,
|
||||||
"plik_enabled": job.plik_enabled,
|
"plik_enabled": job.plik_enabled,
|
||||||
"delete_after_upload": job.delete_after_upload,
|
"delete_after_upload": job.delete_after_upload,
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ DEFAULT_CONFIG = {
|
|||||||
"api_key": "",
|
"api_key": "",
|
||||||
"ttl": "30d",
|
"ttl": "30d",
|
||||||
},
|
},
|
||||||
|
"schedule": {
|
||||||
|
"default_pre_buffer": 5,
|
||||||
|
"default_post_buffer": 5,
|
||||||
|
},
|
||||||
"logging": {
|
"logging": {
|
||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ class Job(Base):
|
|||||||
schedule_stop = Column(String(5), default="")
|
schedule_stop = Column(String(5), default="")
|
||||||
schedule_end_date = Column(String(10), default="")
|
schedule_end_date = Column(String(10), default="")
|
||||||
|
|
||||||
|
pre_buffer = Column(Integer, default=0)
|
||||||
|
post_buffer = Column(Integer, default=0)
|
||||||
|
|
||||||
description = Column(Text, default="")
|
description = Column(Text, default="")
|
||||||
|
|
||||||
plik_enabled = Column(Boolean, default=False)
|
plik_enabled = Column(Boolean, default=False)
|
||||||
@@ -32,10 +35,7 @@ class Job(Base):
|
|||||||
|
|
||||||
ntfy_enabled = Column(Boolean, default=False)
|
ntfy_enabled = Column(Boolean, default=False)
|
||||||
|
|
||||||
metadata_monitor_enabled = Column(Boolean, default=False)
|
station_id = Column(Integer, nullable=True)
|
||||||
metadata_pattern = Column(String(200), default="")
|
|
||||||
metadata_grace_period = Column(Integer, default=300)
|
|
||||||
metadata_poll_interval = Column(Integer, default=30)
|
|
||||||
|
|
||||||
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=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)
|
is_scheduled = Column(Boolean, default=False)
|
||||||
error_message = Column(Text, nullable=True)
|
error_message = Column(Text, nullable=True)
|
||||||
file_size = Column(Integer, nullable=True)
|
file_size = Column(Integer, nullable=True)
|
||||||
|
plik_url = Column(String(1000), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class LogEntry(Base):
|
class LogEntry(Base):
|
||||||
@@ -98,11 +99,18 @@ def init_db(db_path: str):
|
|||||||
|
|
||||||
def _migrate(engine):
|
def _migrate(engine):
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
try:
|
for stmt in [
|
||||||
conn.execute(text("ALTER TABLE recordings ADD COLUMN file_size INTEGER"))
|
"ALTER TABLE recordings ADD COLUMN file_size INTEGER",
|
||||||
conn.commit()
|
"ALTER TABLE jobs ADD COLUMN station_id INTEGER",
|
||||||
except Exception:
|
"ALTER TABLE jobs ADD COLUMN pre_buffer INTEGER DEFAULT 0",
|
||||||
pass
|
"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():
|
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)
|
|
||||||
@@ -95,7 +95,8 @@ class PlikUploader:
|
|||||||
self._notify = notify_fn
|
self._notify = notify_fn
|
||||||
|
|
||||||
def upload_recording(self, output_dir: str, output_file: str, job_name: str,
|
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="")
|
plik_url = self.config.get("plik", "url", default="")
|
||||||
if not plik_url:
|
if not plik_url:
|
||||||
self._log("WARN", f"Plik-Upload übersprungen: Keine URL konfiguriert [{job_name}]", job_name)
|
self._log("WARN", f"Plik-Upload übersprungen: Keine URL konfiguriert [{job_name}]", job_name)
|
||||||
@@ -107,15 +108,15 @@ class PlikUploader:
|
|||||||
def _do_upload():
|
def _do_upload():
|
||||||
try:
|
try:
|
||||||
if segment_mode:
|
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:
|
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:
|
except Exception as e:
|
||||||
self._log("ERROR", f"Plik-Upload Fehler: {e} [{job_name}]", job_name)
|
self._log("ERROR", f"Plik-Upload Fehler: {e} [{job_name}]", job_name)
|
||||||
|
|
||||||
threading.Thread(target=_do_upload, daemon=True).start()
|
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:
|
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)
|
self._log("WARN", f"Plik-Upload übersprungen: Datei fehlt oder leer [{job_name}]", job_name)
|
||||||
return
|
return
|
||||||
@@ -127,6 +128,8 @@ class PlikUploader:
|
|||||||
self._log("INFO", f"Plik-Upload abgeschlossen: {result['browser_url']} [{job_name}]", job_name)
|
self._log("INFO", f"Plik-Upload abgeschlossen: {result['browser_url']} [{job_name}]", job_name)
|
||||||
if result.get("download_url"):
|
if result.get("download_url"):
|
||||||
self._log("INFO", f"Plik-Download: {result['download_url']} [{job_name}]", job_name)
|
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:
|
if self._notify:
|
||||||
self._notify("upload", "Plik-Upload", f"{job_name}: {result['browser_url']}", ntfy_enabled)
|
self._notify("upload", "Plik-Upload", f"{job_name}: {result['browser_url']}", ntfy_enabled)
|
||||||
if delete_after:
|
if delete_after:
|
||||||
@@ -135,7 +138,7 @@ class PlikUploader:
|
|||||||
else:
|
else:
|
||||||
self._log("ERROR", f"Plik-Upload fehlgeschlagen [{job_name}]", job_name)
|
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):
|
if not os.path.isdir(output_dir):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -161,8 +164,11 @@ class PlikUploader:
|
|||||||
else:
|
else:
|
||||||
self._log("ERROR", f"Plik Segment-Upload fehlgeschlagen: {fname} [{job_name}]", job_name)
|
self._log("ERROR", f"Plik Segment-Upload fehlgeschlagen: {fname} [{job_name}]", job_name)
|
||||||
|
|
||||||
if uploaded > 0 and self._notify:
|
if uploaded > 0:
|
||||||
msg = f"{job_name}: {uploaded} Segment(e) hochgeladen"
|
if save_url_fn and last_browser_url:
|
||||||
if last_browser_url:
|
save_url_fn(last_browser_url)
|
||||||
msg += f"\n{last_browser_url}"
|
if self._notify:
|
||||||
self._notify("upload", "Plik-Upload", msg, ntfy_enabled)
|
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)
|
||||||
|
|||||||
@@ -65,21 +65,11 @@ class RecordingManager:
|
|||||||
self._processes: dict[int, subprocess.Popen] = {}
|
self._processes: dict[int, subprocess.Popen] = {}
|
||||||
self._stop_events: dict[int, threading.Event] = {}
|
self._stop_events: dict[int, threading.Event] = {}
|
||||||
self._threads: dict[int, threading.Thread] = {}
|
self._threads: dict[int, threading.Thread] = {}
|
||||||
self._metadata_monitor = None
|
|
||||||
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]] = {}
|
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):
|
def _get_ntfy(self):
|
||||||
if self._ntfy is None:
|
if self._ntfy is None:
|
||||||
from plugins.ntfy import NtfyNotifier
|
from plugins.ntfy import NtfyNotifier
|
||||||
@@ -126,10 +116,6 @@ class RecordingManager:
|
|||||||
segment_duration: Optional[int] = None,
|
segment_duration: Optional[int] = None,
|
||||||
is_scheduled: bool = False,
|
is_scheduled: bool = False,
|
||||||
description: str = "",
|
description: str = "",
|
||||||
metadata_monitor_enabled: bool = False,
|
|
||||||
metadata_pattern: str = "",
|
|
||||||
metadata_grace_period: int = 300,
|
|
||||||
metadata_poll_interval: int = 30,
|
|
||||||
ntfy_enabled: bool = False,
|
ntfy_enabled: bool = False,
|
||||||
plik_enabled: bool = False,
|
plik_enabled: bool = False,
|
||||||
delete_after_upload: bool = False,
|
delete_after_upload: bool = False,
|
||||||
@@ -199,16 +185,6 @@ class RecordingManager:
|
|||||||
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)
|
||||||
|
|
||||||
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}
|
return {"recording_id": rec_id, "output_dir": output_dir, "output_file": output_file}
|
||||||
|
|
||||||
def _recording_loop(
|
def _recording_loop(
|
||||||
@@ -331,9 +307,6 @@ class RecordingManager:
|
|||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
def stop_recording(self, rec_id: int) -> bool:
|
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)
|
stop_event = self._stop_events.get(rec_id)
|
||||||
if stop_event:
|
if stop_event:
|
||||||
stop_event.set()
|
stop_event.set()
|
||||||
@@ -487,6 +460,7 @@ class RecordingManager:
|
|||||||
"segment_count": segment_count if rec.segment_mode else None,
|
"segment_count": segment_count if rec.segment_mode else None,
|
||||||
"remaining": remaining,
|
"remaining": remaining,
|
||||||
"remaining_display": format_elapsed(remaining) if remaining is not None else None,
|
"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]:
|
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,
|
segment_mode=segment_mode,
|
||||||
delete_after=opts.get("delete_after_upload", False),
|
delete_after=opts.get("delete_after_upload", False),
|
||||||
ntfy_enabled=opts.get("ntfy_enabled", 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):
|
def _cleanup(self, rec_id: int):
|
||||||
|
|||||||
@@ -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
|
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:
|
class StreamScheduler:
|
||||||
def __init__(self, recorder):
|
def __init__(self, recorder):
|
||||||
self.recorder = recorder
|
self.recorder = recorder
|
||||||
@@ -76,6 +93,12 @@ 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 _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:
|
def _schedule_state(self, job: dict, now: datetime.datetime | None = None) -> dict:
|
||||||
now = now or datetime.datetime.now()
|
now = now or datetime.datetime.now()
|
||||||
today_num = now.isoweekday()
|
today_num = now.isoweekday()
|
||||||
@@ -140,15 +163,19 @@ class StreamScheduler:
|
|||||||
"schedule_start": j.schedule_start,
|
"schedule_start": j.schedule_start,
|
||||||
"schedule_stop": j.schedule_stop,
|
"schedule_stop": j.schedule_stop,
|
||||||
"schedule_end_date": j.schedule_end_date,
|
"schedule_end_date": j.schedule_end_date,
|
||||||
|
"pre_buffer": j.pre_buffer or 0,
|
||||||
|
"post_buffer": j.post_buffer or 0,
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
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"]:
|
if not state["in_window"]:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self._started_keys.add(state["occ_key"])
|
self._started_keys.add(occ_key)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _occurrence_start_utc(self, job: dict, now: datetime.datetime) -> datetime.datetime | None:
|
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_once": j.schedule_once, "schedule_date": j.schedule_date,
|
||||||
"schedule_days": j.schedule_days, "schedule_start": j.schedule_start,
|
"schedule_days": j.schedule_days, "schedule_start": j.schedule_start,
|
||||||
"schedule_stop": j.schedule_stop, "schedule_end_date": j.schedule_end_date,
|
"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,
|
"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,
|
"ntfy_enabled": j.ntfy_enabled,
|
||||||
"plik_enabled": j.plik_enabled,
|
"plik_enabled": j.plik_enabled,
|
||||||
"delete_after_upload": j.delete_after_upload,
|
"delete_after_upload": j.delete_after_upload,
|
||||||
@@ -235,9 +259,10 @@ class StreamScheduler:
|
|||||||
if not job["schedule_start"]:
|
if not job["schedule_start"]:
|
||||||
continue
|
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"]
|
in_window = state["in_window"]
|
||||||
occ_key = state["occ_key"]
|
|
||||||
|
|
||||||
if in_window:
|
if in_window:
|
||||||
if (
|
if (
|
||||||
@@ -246,16 +271,16 @@ class StreamScheduler:
|
|||||||
and not self._has_recording_for_occurrence(job, now)
|
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 eff_job["schedule_stop"]:
|
||||||
if state["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']} {eff_job['schedule_start']}", "%Y-%m-%d %H:%M")
|
||||||
end_dt = datetime.datetime.strptime(
|
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()))
|
duration = max(0, int((end_dt - start_dt).total_seconds()))
|
||||||
else:
|
else:
|
||||||
start_min = _time_to_minutes(job["schedule_start"])
|
start_min = _time_to_minutes(eff_job["schedule_start"])
|
||||||
stop_min = _time_to_minutes(job["schedule_stop"])
|
stop_min = _time_to_minutes(eff_job["schedule_stop"])
|
||||||
if stop_min > start_min:
|
if stop_min > start_min:
|
||||||
duration = (stop_min - start_min) * 60
|
duration = (stop_min - start_min) * 60
|
||||||
else:
|
else:
|
||||||
@@ -272,10 +297,6 @@ class StreamScheduler:
|
|||||||
segment_duration=job["segment_duration"],
|
segment_duration=job["segment_duration"],
|
||||||
is_scheduled=True,
|
is_scheduled=True,
|
||||||
description=job.get("description", ""),
|
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),
|
ntfy_enabled=job.get("ntfy_enabled", False),
|
||||||
plik_enabled=job.get("plik_enabled", False),
|
plik_enabled=job.get("plik_enabled", False),
|
||||||
delete_after_upload=job.get("delete_after_upload", False),
|
delete_after_upload=job.get("delete_after_upload", False),
|
||||||
@@ -285,7 +306,7 @@ class StreamScheduler:
|
|||||||
if (
|
if (
|
||||||
not state["multi_day"]
|
not state["multi_day"]
|
||||||
and not state["is_cross_midnight"]
|
and not state["is_cross_midnight"]
|
||||||
and job["schedule_stop"]
|
and eff_job["schedule_stop"]
|
||||||
and self._is_job_recording(job["id"])
|
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"])
|
||||||
|
|||||||
@@ -38,5 +38,10 @@ plik:
|
|||||||
api_key: ""
|
api_key: ""
|
||||||
ttl: "30d"
|
ttl: "30d"
|
||||||
|
|
||||||
|
# Zeitplan-Puffer (Minuten) – Standardwerte für neue Jobs
|
||||||
|
schedule:
|
||||||
|
default_pre_buffer: 5
|
||||||
|
default_post_buffer: 5
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level: INFO
|
level: INFO
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -4,6 +4,7 @@
|
|||||||
<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>
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #0f1117;
|
--bg: #0f1117;
|
||||||
@@ -54,7 +55,15 @@
|
|||||||
gap: 10px;
|
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;
|
width: 10px; height: 10px;
|
||||||
background: var(--recording);
|
background: var(--recording);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -307,13 +316,49 @@
|
|||||||
.remaining.low { color: var(--warning); }
|
.remaining.low { color: var(--warning); }
|
||||||
.remaining.critical { color: var(--error); }
|
.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 */
|
/* Responsive */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.app { padding: 10px; }
|
.app { padding: 10px; }
|
||||||
.form-grid { grid-template-columns: 1fr; }
|
.form-grid { grid-template-columns: 1fr; }
|
||||||
.tabs { flex-wrap: wrap; }
|
.burger { display: block; }
|
||||||
.tab { padding: 6px 12px; font-size: 0.8rem; }
|
.tabs {
|
||||||
header { flex-direction: column; gap: 12px; align-items: flex-start; }
|
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; }
|
header h1 { font-size: 1.1rem; }
|
||||||
.card { padding: 12px; }
|
.card { padding: 12px; }
|
||||||
.card-header { flex-direction: column; align-items: flex-start; gap: 8px; }
|
.card-header { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||||
@@ -340,6 +385,29 @@
|
|||||||
border-bottom: 1px solid var(--border);
|
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 {
|
.site-footer {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -366,8 +434,9 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<header>
|
<header>
|
||||||
<h1><span class="rec-dot" id="recDot"></span> Stream-Recorder</h1>
|
<h1><img src="/logo.png" alt="Logo" class="header-logo" id="headerLogo"> Stream-Recorder</h1>
|
||||||
<div class="tabs">
|
<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 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('stations')">Sender</button>
|
||||||
@@ -487,7 +556,7 @@
|
|||||||
<option value="">URL manuell eingeben</option>
|
<option value="">URL manuell eingeben</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group full">
|
<div class="form-group full" id="jobUrlGroup">
|
||||||
<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">
|
||||||
</div>
|
</div>
|
||||||
@@ -567,6 +636,23 @@
|
|||||||
<input type="time" id="job-schedule-stop" oninput="updateDurationField()">
|
<input type="time" id="job-schedule-stop" oninput="updateDurationField()">
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -586,28 +672,7 @@
|
|||||||
<div id="plikFields" style="display:none; margin-top:8px; margin-left:24px">
|
<div id="plikFields" style="display:none; margin-top:8px; margin-left:24px">
|
||||||
<div class="checkbox-group">
|
<div class="checkbox-group">
|
||||||
<input type="checkbox" id="job-delete-after">
|
<input type="checkbox" id="job-delete-after">
|
||||||
<label for="job-delete-after" style="margin:0">Nach Upload löschen</label>
|
<label for="job-delete-after" style="margin:0">File nach Upload lokal 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">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -683,6 +748,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<script>
|
||||||
const API = '';
|
const API = '';
|
||||||
let stationsCache = [];
|
let stationsCache = [];
|
||||||
@@ -699,8 +778,14 @@ async function api(path, opts = {}) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Burger menu
|
||||||
|
function toggleBurger() {
|
||||||
|
document.getElementById('navTabs').classList.toggle('open');
|
||||||
|
}
|
||||||
|
|
||||||
// Tabs
|
// Tabs
|
||||||
function switchTab(name) {
|
function switchTab(name) {
|
||||||
|
document.getElementById('navTabs').classList.remove('open');
|
||||||
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');
|
||||||
@@ -727,10 +812,6 @@ async function loadDashboard() {
|
|||||||
const active = await api('/api/recordings?active_only=true');
|
const active = await api('/api/recordings?active_only=true');
|
||||||
const recent = await api('/api/recordings?limit=20');
|
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);
|
renderActive(active);
|
||||||
renderRecent(recent.filter(r => r.status !== 'running' && r.status !== 'starting'));
|
renderRecent(recent.filter(r => r.status !== 'running' && r.status !== 'starting'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -752,7 +833,7 @@ function renderActive(recs) {
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
<span style="color:var(--recording)">●</span>
|
<span class="rec-dot"></span>
|
||||||
${esc(r.job_name)}
|
${esc(r.job_name)}
|
||||||
<span class="badge badge-${r.status}">${r.status}</span>
|
<span class="badge badge-${r.status}">${r.status}</span>
|
||||||
${r.is_scheduled ? '<span class="badge badge-scheduled">geplant</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>';
|
el.innerHTML = '<div class="empty" style="padding:24px"><span style="color:var(--text-dim)">Noch keine abgeschlossenen Aufnahmen</span></div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
el.innerHTML = recs.slice(0, 10).map(r => `
|
el.innerHTML = recs.slice(0, 10).map(r => {
|
||||||
<div class="card" style="padding:12px">
|
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 style="display:flex;align-items:center;justify-content:space-between">
|
||||||
<div>
|
<div>
|
||||||
<span style="font-weight:500">${esc(r.job_name)}</span>
|
<span style="font-weight:500">${esc(r.job_name)}</span>
|
||||||
<span class="badge badge-${r.status}" style="margin-left:8px">${r.status}</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>
|
||||||
<div class="meta">
|
<div class="recent-meta">
|
||||||
<span class="meta-item">🕓 ${r.elapsed_display}</span>
|
<span class="rm-dur">🕓 ${r.elapsed_display}</span>
|
||||||
<span class="meta-item">💾 ${r.total_size_display}</span>
|
<span class="rm-size">💾 ${r.total_size_display}</span>
|
||||||
<span class="meta-item">${r.started_at ? new Date(r.started_at).toLocaleString('de-DE') : ''}</span>
|
<span class="rm-date">${started}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>`;
|
||||||
`).join('');
|
}).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
|
// Stations
|
||||||
@@ -908,6 +1066,7 @@ function applyStationToQuickRecord() {
|
|||||||
function applyStationToJob() {
|
function applyStationToJob() {
|
||||||
const id = document.getElementById('job-station').value;
|
const id = document.getElementById('job-station').value;
|
||||||
const station = stationsCache.find(s => String(s.id) === id);
|
const station = stationsCache.find(s => String(s.id) === id);
|
||||||
|
document.getElementById('jobUrlGroup').style.display = station ? 'none' : '';
|
||||||
if (!station) return;
|
if (!station) return;
|
||||||
document.getElementById('job-url').value = station.stream_url;
|
document.getElementById('job-url').value = station.stream_url;
|
||||||
document.getElementById('job-type').value = station.stream_type || 'auto';
|
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>` : ''}
|
${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">
|
<div class="meta">
|
||||||
<span class="meta-item">${esc(j.stream_type || 'auto')}</span>
|
<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.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.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.ntfy_enabled ? `<span class="meta-item">🔔 NTFY</span>` : ''}
|
||||||
${j.plik_enabled ? `<span class="meta-item">📤 Plik${j.delete_after_upload ? ' (löschen)' : ''}</span>` : ''}
|
${j.plik_enabled ? `<span class="meta-item">📤 Plik${j.delete_after_upload ? ' (danach löschen)' : ''}</span>` : ''}
|
||||||
${j.metadata_monitor_enabled ? `<span class="meta-item">📡 ${esc(j.metadata_pattern)}</span>` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -964,7 +1122,8 @@ function openJobModal(job = null) {
|
|||||||
loadStationOptions();
|
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-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-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';
|
||||||
@@ -983,14 +1142,15 @@ function openJobModal(job = null) {
|
|||||||
document.getElementById('job-ntfy-enabled').checked = job ? job.ntfy_enabled : false;
|
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-plik-enabled').checked = job ? job.plik_enabled : false;
|
||||||
document.getElementById('job-delete-after').checked = job ? job.delete_after_upload : 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-pre-buffer-enabled').checked = job ? (job.pre_buffer || 0) > 0 : false;
|
||||||
document.getElementById('job-metadata-pattern').value = job ? job.metadata_pattern || '' : '';
|
document.getElementById('job-pre-buffer').value = job && job.pre_buffer ? job.pre_buffer : '';
|
||||||
document.getElementById('job-metadata-grace').value = job ? Math.round((job.metadata_grace_period || 300) / 60) : 5;
|
document.getElementById('job-post-buffer-enabled').checked = job ? (job.post_buffer || 0) > 0 : false;
|
||||||
document.getElementById('job-metadata-poll').value = job ? job.metadata_poll_interval || 30 : 30;
|
document.getElementById('job-post-buffer').value = job && job.post_buffer ? job.post_buffer : '';
|
||||||
toggleSchedule();
|
toggleSchedule();
|
||||||
toggleScheduleOnce();
|
toggleScheduleOnce();
|
||||||
|
togglePreBuffer();
|
||||||
|
togglePostBuffer();
|
||||||
togglePlik();
|
togglePlik();
|
||||||
toggleMetadata();
|
|
||||||
openModal('jobModal');
|
openModal('jobModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1018,14 +1178,13 @@ async function saveJob() {
|
|||||||
schedule_start: document.getElementById('job-schedule-start').value,
|
schedule_start: document.getElementById('job-schedule-start').value,
|
||||||
schedule_stop: document.getElementById('job-schedule-stop').value,
|
schedule_stop: document.getElementById('job-schedule-stop').value,
|
||||||
schedule_end_date: document.getElementById('job-schedule-end-date').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,
|
description: document.getElementById('job-description').value,
|
||||||
ntfy_enabled: document.getElementById('job-ntfy-enabled').checked,
|
ntfy_enabled: document.getElementById('job-ntfy-enabled').checked,
|
||||||
plik_enabled: document.getElementById('job-plik-enabled').checked,
|
plik_enabled: document.getElementById('job-plik-enabled').checked,
|
||||||
delete_after_upload: document.getElementById('job-delete-after').checked,
|
delete_after_upload: document.getElementById('job-delete-after').checked,
|
||||||
metadata_monitor_enabled: document.getElementById('job-metadata-enabled').checked,
|
station_id: document.getElementById('job-station').value ? parseInt(document.getElementById('job-station').value) : null,
|
||||||
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'),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!data.name || !data.stream_url) {
|
if (!data.name || !data.stream_url) {
|
||||||
@@ -1081,11 +1240,14 @@ function updateDurationField() {
|
|||||||
document.getElementById('durationAutoGroup').style.display = hasScheduleStop ? '' : 'none';
|
document.getElementById('durationAutoGroup').style.display = hasScheduleStop ? '' : 'none';
|
||||||
|
|
||||||
if (hasScheduleStop) {
|
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;
|
let text;
|
||||||
if (once && startDate && endDate && endDate !== startDate) {
|
if (once && startDate && endDate && endDate !== startDate) {
|
||||||
const startDt = new Date(`${startDate}T${start}`);
|
const startDt = new Date(`${startDate}T${start}`);
|
||||||
const endDt = new Date(`${endDate}T${stop}`);
|
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'; }
|
if (diffMin <= 0) { text = 'Enddatum muss nach Startdatum liegen'; }
|
||||||
else {
|
else {
|
||||||
const d = Math.floor(diffMin / 1440);
|
const d = Math.floor(diffMin / 1440);
|
||||||
@@ -1096,15 +1258,18 @@ function updateDurationField() {
|
|||||||
if (h > 0) parts.push(`${h} Std.`);
|
if (h > 0) parts.push(`${h} Std.`);
|
||||||
if (m > 0) parts.push(`${m} Min.`);
|
if (m > 0) parts.push(`${m} Min.`);
|
||||||
text = `${parts.join(' ')} (mehrtägig, aus Zeitplan)`;
|
text = `${parts.join(' ')} (mehrtägig, aus Zeitplan)`;
|
||||||
|
if (bufTotal > 0) text += ` inkl. ${bufTotal} Min. Puffer`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const [sh, sm] = start.split(':').map(Number);
|
const [sh, sm] = start.split(':').map(Number);
|
||||||
const [eh, em] = stop.split(':').map(Number);
|
const [eh, em] = stop.split(':').map(Number);
|
||||||
let diff = (eh * 60 + em) - (sh * 60 + sm);
|
let diff = (eh * 60 + em) - (sh * 60 + sm);
|
||||||
if (diff <= 0) diff += 1440;
|
if (diff <= 0) diff += 1440;
|
||||||
|
diff += bufTotal;
|
||||||
const h = Math.floor(diff / 60);
|
const h = Math.floor(diff / 60);
|
||||||
const m = 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;
|
document.getElementById('durationAutoText').textContent = text;
|
||||||
}
|
}
|
||||||
@@ -1118,16 +1283,31 @@ function toggleScheduleOnce() {
|
|||||||
updateDurationField();
|
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() {
|
function togglePlik() {
|
||||||
document.getElementById('plikFields').style.display =
|
document.getElementById('plikFields').style.display =
|
||||||
document.getElementById('job-plik-enabled').checked ? 'block' : 'none';
|
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
|
// Quick Record
|
||||||
function openQuickRecord() {
|
function openQuickRecord() {
|
||||||
loadStationOptions();
|
loadStationOptions();
|
||||||
@@ -1250,6 +1430,19 @@ async function loadSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div class="settings-section">
|
||||||
<h3>NTFY-Benachrichtigungen</h3>
|
<h3>NTFY-Benachrichtigungen</h3>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
@@ -1298,12 +1491,19 @@ async function saveSettings() {
|
|||||||
};
|
};
|
||||||
if (passVal && passVal !== '***') authData.password = passVal;
|
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 = {
|
const data = {
|
||||||
recording: {
|
recording: {
|
||||||
download_path: document.getElementById('cfg-download-path').value,
|
download_path: document.getElementById('cfg-download-path').value,
|
||||||
max_retries: parseInt(document.getElementById('cfg-max-retries').value) || 5,
|
max_retries: parseInt(document.getElementById('cfg-max-retries').value) || 5,
|
||||||
retry_delay: parseInt(document.getElementById('cfg-retry-delay').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,
|
auth: authData,
|
||||||
ntfy: {
|
ntfy: {
|
||||||
url: document.getElementById('cfg-ntfy-url').value,
|
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
|
// Auto-refresh
|
||||||
loadStationOptions();
|
loadStationOptions();
|
||||||
|
loadDefaults();
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
setInterval(loadDashboard, 5000);
|
setInterval(loadDashboard, 5000);
|
||||||
|
|
||||||
|
api('/api/health').then(h => {
|
||||||
|
document.getElementById('footerVersion').textContent = 'Stream-Recorder v' + h.version;
|
||||||
|
}).catch(() => {});
|
||||||
</script>
|
</script>
|
||||||
<footer class="site-footer">
|
<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>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 135 KiB |
@@ -4,16 +4,16 @@ services:
|
|||||||
container_name: stream-recorder
|
container_name: stream-recorder
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT_BIND:-8484}:8484"
|
- "8484:8484"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
# Optional: Recordings auf separatem Pfad (dann download_path in Einstellungen anpassen)
|
# Optional: Recordings auf separatem Pfad (dann download_path in Einstellungen anpassen)
|
||||||
# - /mnt/storage/recordings:/recordings
|
# - /mnt/storage/recordings:/recordings
|
||||||
networks:
|
networks:
|
||||||
stream-recorder_net:
|
stream-recorder_net:
|
||||||
ipv4_address: ${APP_IPV4_ADDRESS:-172.16.84.10}
|
ipv4_address: 172.16.84.10
|
||||||
environment:
|
environment:
|
||||||
TZ: ${TZ:-Europe/Berlin}
|
TZ: Europe/Berlin
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8484/api/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8484/api/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -23,10 +23,10 @@ services:
|
|||||||
|
|
||||||
networks:
|
networks:
|
||||||
stream-recorder_net:
|
stream-recorder_net:
|
||||||
name: ${DOCKER_NETWORK_NAME:-stream-recorder.dockernetwork.local}
|
name: stream-recorder.dockernetwork.local
|
||||||
driver: bridge
|
driver: bridge
|
||||||
ipam:
|
ipam:
|
||||||
config:
|
config:
|
||||||
- subnet: ${DOCKER_NETWORK_SUBNET:-172.16.84.0/24}
|
- subnet: 172.16.84.0/24
|
||||||
gateway: ${DOCKER_NETWORK_GATEWAY:-172.16.84.1}
|
gateway: 172.16.84.1
|
||||||
ip_range: ${DOCKER_NETWORK_IP_RANGE:-172.16.84.128/25}
|
ip_range: 172.16.84.128/25
|
||||||
|
|||||||
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 58 KiB |
@@ -2,14 +2,29 @@
|
|||||||
|
|
||||||
## Aktive Aufnahmen
|
## Aktive Aufnahmen
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
Das Dashboard zeigt alle laufenden Aufnahmen mit:
|
Das Dashboard zeigt alle laufenden Aufnahmen mit:
|
||||||
- Laufzeit
|
- Laufzeit
|
||||||
- Dateigröße
|
- Dateigröße
|
||||||
- Verbleibende Zeit (bei Zeitlimit)
|
- Verbleibende Zeit (bei Zeitlimit)
|
||||||
- Segment-Anzahl (bei Segment-Splitting)
|
- Segment-Anzahl (bei Segment-Splitting)
|
||||||
|
|
||||||
|
## Letzte Aufnahmen
|
||||||
|
|
||||||
|
Unterhalb der aktiven Aufnahmen zeigt das Dashboard die letzten abgeschlossenen Aufnahmen. Per Klick auf eine Aufnahme wird eine Detailansicht ausgeklappt mit:
|
||||||
|
|
||||||
|
- **Status** — abgeschlossen, gestoppt oder Fehler (inkl. Fehlermeldung)
|
||||||
|
- **Start- und Endzeit**
|
||||||
|
- **Dauer und Dateigröße**
|
||||||
|
- **Plik-URL** — Direkter Link zum Download, falls die Aufnahme zu Plik hochgeladen wurde
|
||||||
|
- **Dateipfad** — Lokaler Pfad zur Aufnahmedatei (sofern nicht nach Upload gelöscht)
|
||||||
|
- **Stream-URL**
|
||||||
|
|
||||||
## Aufnahme verlängern
|
## Aufnahme verlängern
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
Laufende Aufnahmen können verlängert werden:
|
Laufende Aufnahmen können verlängert werden:
|
||||||
- **+15 / +30 / +60 / +120 Minuten** — Schnellauswahl
|
- **+15 / +30 / +60 / +120 Minuten** — Schnellauswahl
|
||||||
- **Eigene Dauer** — Beliebige Minutenzahl eingeben
|
- **Eigene Dauer** — Beliebige Minutenzahl eingeben
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Jobs
|
# Jobs
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
Jobs sind vorkonfigurierte Aufnahme-Vorlagen. Jeder Job definiert einen Stream, optional einen Zeitplan und Plugin-Einstellungen.
|
Jobs sind vorkonfigurierte Aufnahme-Vorlagen. Jeder Job definiert einen Stream, optional einen Zeitplan und Plugin-Einstellungen.
|
||||||
|
|
||||||
## Job erstellen
|
## Job erstellen
|
||||||
@@ -17,6 +19,8 @@ Im Web-UI unter **Jobs → + Neuer Job**.
|
|||||||
|
|
||||||
| Feld | Beschreibung |
|
| Feld | Beschreibung |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
|
| **Senderbibliothek** | Sender aus der Bibliothek auswählen — URL und Typ werden übernommen |
|
||||||
|
| **Beschreibung** | Freitext-Notiz zum Job (wird auch in NTFY-Nachrichten mitgeschickt) |
|
||||||
| **Typ** | Automatisch erkannt aus URL. Manuell wählbar: RTMP, HLS, MP3, AAC, OGG, HTTP |
|
| **Typ** | Automatisch erkannt aus URL. Manuell wählbar: RTMP, HLS, MP3, AAC, OGG, HTTP |
|
||||||
| **Ausgabeformat** | Dateiendung. Leer = automatisch (mp4 für Video, mp3 für Audio) |
|
| **Ausgabeformat** | Dateiendung. Leer = automatisch (mp4 für Video, mp3 für Audio) |
|
||||||
| **Max. Dauer** | Zeitlimit in Minuten. Entfällt bei Zeitplan mit Endzeit |
|
| **Max. Dauer** | Zeitlimit in Minuten. Entfällt bei Zeitplan mit Endzeit |
|
||||||
@@ -30,7 +34,8 @@ Aktiviert automatische Aufnahmen zu festgelegten Zeiten.
|
|||||||
| Feld | Beschreibung |
|
| Feld | Beschreibung |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| **Einmalig** | Nur einmal am angegebenen Datum ausführen |
|
| **Einmalig** | Nur einmal am angegebenen Datum ausführen |
|
||||||
| **Datum** | Datum für einmalige Aufnahme |
|
| **Startdatum** | Datum für einmalige Aufnahme |
|
||||||
|
| **Enddatum** | Enddatum für mehrtägige Aufnahmen (leer = gleicher Tag) |
|
||||||
| **Tage** | Wochentage: `*` (täglich), `Mo-Fr`, `Sa,So`, `Mo,Mi,Fr` |
|
| **Tage** | Wochentage: `*` (täglich), `Mo-Fr`, `Sa,So`, `Mo,Mi,Fr` |
|
||||||
| **Startzeit** | Aufnahme-Beginn (HH:MM) |
|
| **Startzeit** | Aufnahme-Beginn (HH:MM) |
|
||||||
| **Endzeit** | Aufnahme-Ende (HH:MM) — Dauer wird automatisch berechnet |
|
| **Endzeit** | Aufnahme-Ende (HH:MM) — Dauer wird automatisch berechnet |
|
||||||
@@ -49,10 +54,39 @@ Wenn Start- und Endzeit gesetzt sind, wird die Aufnahmedauer automatisch berechn
|
|||||||
|
|
||||||
Unterstützt: `Mo/Mon/Montag`, `Di/Tue/Dienstag`, `Mi/Wed/Mittwoch`, `Do/Thu/Donnerstag`, `Fr/Fri/Freitag`, `Sa/Sat/Samstag`, `So/Sun/Sonntag`
|
Unterstützt: `Mo/Mon/Montag`, `Di/Tue/Dienstag`, `Mi/Wed/Mittwoch`, `Do/Thu/Donnerstag`, `Fr/Fri/Freitag`, `Sa/Sat/Samstag`, `So/Sun/Sonntag`
|
||||||
|
|
||||||
|
### Zeitpuffer
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Pro Job können Vor- und Nachlaufzeit konfiguriert werden, um die Aufnahme um den geplanten Zeitraum herum zu erweitern:
|
||||||
|
|
||||||
|
| Feld | Beschreibung |
|
||||||
|
|------|-------------|
|
||||||
|
| **Vorlaufzeit** | Aufnahme startet X Minuten vor der geplanten Startzeit |
|
||||||
|
| **Nachlaufzeit** | Aufnahme läuft X Minuten über die geplante Endzeit hinaus |
|
||||||
|
|
||||||
|
Die Standardwerte können in den Einstellungen unter **Zeitplan-Puffer** festgelegt werden.
|
||||||
|
|
||||||
|
### Mehrtägige Aufnahmen
|
||||||
|
|
||||||
|
Bei einmaligen Aufnahmen kann ein **Enddatum** angegeben werden, das nach dem Startdatum liegt. Die Aufnahmedauer wird dann automatisch über die Tagesgrenzen hinweg berechnet.
|
||||||
|
|
||||||
|
## Plugins pro Job
|
||||||
|
|
||||||
|
Jeder Job kann individuell Plugin-Funktionen aktivieren:
|
||||||
|
|
||||||
|
| Option | Beschreibung |
|
||||||
|
|--------|-------------|
|
||||||
|
| **Benachrichtigungen (NTFY)** | Push-Nachrichten bei Start, Stop und Fehlern für diesen Job |
|
||||||
|
| **Plik-Upload** | Automatischer Upload nach Aufnahme-Ende |
|
||||||
|
| **File nach Upload lokal löschen** | Lokale Datei nach erfolgreichem Plik-Upload entfernen |
|
||||||
|
|
||||||
|
Die globale NTFY- und Plik-Konfiguration (URL, Token, etc.) erfolgt in den [Einstellungen](konfiguration.md).
|
||||||
|
|
||||||
## Manuelle Aufnahme
|
## Manuelle Aufnahme
|
||||||
|
|
||||||
Jobs können auch manuell über den **Aufnehmen**-Button gestartet werden, unabhängig vom Zeitplan.
|
Jobs können auch manuell über den **Aufnehmen**-Button gestartet werden, unabhängig vom Zeitplan.
|
||||||
|
|
||||||
## Schnellaufnahme
|
## Schnellaufnahme
|
||||||
|
|
||||||
Für einmalige Aufnahmen ohne Job: **Dashboard → + Schnellaufnahme**. URL eingeben, optional Name und Dauer angeben, sofort starten.
|
Für einmalige Aufnahmen ohne Job: **Dashboard → + Schnellaufnahme**. URL eingeben (oder aus der Senderbibliothek wählen), optional Name und Dauer angeben, sofort starten.
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
## config.yml
|
## config.yml
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
Die Konfiguration liegt in `data/config.yml` und kann über das Web-UI unter **Einstellungen** bearbeitet werden.
|
Die Konfiguration liegt in `data/config.yml` und kann über das Web-UI unter **Einstellungen** bearbeitet werden.
|
||||||
|
|
||||||
Beim ersten Start wird die Datei automatisch mit Standardwerten erstellt. Eine Vorlage mit Kommentaren findet sich in `config/config.yml.dist`.
|
Beim ersten Start wird die Datei automatisch mit Standardwerten erstellt. Eine Vorlage mit Kommentaren findet sich in `bin/config.yml.dist`.
|
||||||
|
|
||||||
## Abschnitte
|
## Abschnitte
|
||||||
|
|
||||||
@@ -13,9 +15,16 @@ Beim ersten Start wird die Datei automatisch mit Standardwerten erstellt. Eine V
|
|||||||
| Parameter | Standard | Beschreibung |
|
| Parameter | Standard | Beschreibung |
|
||||||
|-----------|----------|-------------|
|
|-----------|----------|-------------|
|
||||||
| `recording.download_path` | `/app/data/recordings` | Speicherort für Aufnahmen |
|
| `recording.download_path` | `/app/data/recordings` | Speicherort für Aufnahmen |
|
||||||
| `recording.max_retries` | `5` | Max. Reconnect-Versuche bei Verbindungsabbruch |
|
| `recording.max_retries` | `5` | Max. Reconnect-Versuche bei Verbindungsabbruch (0 = unbegrenzt) |
|
||||||
| `recording.retry_delay` | `5` | Wartezeit (Sekunden) zwischen Reconnect-Versuchen |
|
| `recording.retry_delay` | `5` | Wartezeit (Sekunden) zwischen Reconnect-Versuchen |
|
||||||
|
|
||||||
|
### Server
|
||||||
|
|
||||||
|
| Parameter | Standard | Beschreibung |
|
||||||
|
|-----------|----------|-------------|
|
||||||
|
| `server.host` | `0.0.0.0` | Bind-Adresse des Webservers |
|
||||||
|
| `server.port` | `8484` | Port des Webservers |
|
||||||
|
|
||||||
### Authentifizierung
|
### Authentifizierung
|
||||||
|
|
||||||
| Parameter | Standard | Beschreibung |
|
| Parameter | Standard | Beschreibung |
|
||||||
@@ -24,6 +33,15 @@ Beim ersten Start wird die Datei automatisch mit Standardwerten erstellt. Eine V
|
|||||||
| `auth.username` | `admin` | Benutzername |
|
| `auth.username` | `admin` | Benutzername |
|
||||||
| `auth.password` | `stream-recorder` | Passwort |
|
| `auth.password` | `stream-recorder` | Passwort |
|
||||||
|
|
||||||
|
### Zeitplan-Puffer
|
||||||
|
|
||||||
|
Standardwerte für die Vor- und Nachlaufzeit bei neuen Jobs mit Zeitplan.
|
||||||
|
|
||||||
|
| Parameter | Standard | Beschreibung |
|
||||||
|
|-----------|----------|-------------|
|
||||||
|
| `schedule.default_pre_buffer` | `5` | Standard-Vorlaufzeit (Minuten) |
|
||||||
|
| `schedule.default_post_buffer` | `5` | Standard-Nachlaufzeit (Minuten) |
|
||||||
|
|
||||||
### NTFY
|
### NTFY
|
||||||
|
|
||||||
| Parameter | Standard | Beschreibung |
|
| Parameter | Standard | Beschreibung |
|
||||||
@@ -40,6 +58,12 @@ Beim ersten Start wird die Datei automatisch mit Standardwerten erstellt. Eine V
|
|||||||
| `plik.api_key` | _(leer)_ | API Key für authentifizierte Uploads |
|
| `plik.api_key` | _(leer)_ | API Key für authentifizierte Uploads |
|
||||||
| `plik.ttl` | `30d` | Aufbewahrungsdauer der Uploads (`30d`, `12h`, etc.) |
|
| `plik.ttl` | `30d` | Aufbewahrungsdauer der Uploads (`30d`, `12h`, etc.) |
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
| Parameter | Standard | Beschreibung |
|
||||||
|
|-----------|----------|-------------|
|
||||||
|
| `logging.level` | `INFO` | Log-Level: `DEBUG`, `INFO`, `WARN`, `ERROR` |
|
||||||
|
|
||||||
## Beispiel
|
## Beispiel
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -48,11 +72,19 @@ recording:
|
|||||||
max_retries: 5
|
max_retries: 5
|
||||||
retry_delay: 5
|
retry_delay: 5
|
||||||
|
|
||||||
|
server:
|
||||||
|
host: "0.0.0.0"
|
||||||
|
port: 8484
|
||||||
|
|
||||||
auth:
|
auth:
|
||||||
enabled: true
|
enabled: true
|
||||||
username: admin
|
username: admin
|
||||||
password: mein-passwort
|
password: mein-passwort
|
||||||
|
|
||||||
|
schedule:
|
||||||
|
default_pre_buffer: 5
|
||||||
|
default_post_buffer: 5
|
||||||
|
|
||||||
ntfy:
|
ntfy:
|
||||||
url: https://ntfy.sh/stream-recorder
|
url: https://ntfy.sh/stream-recorder
|
||||||
token: ""
|
token: ""
|
||||||
@@ -62,4 +94,7 @@ plik:
|
|||||||
url: https://plik.example.com
|
url: https://plik.example.com
|
||||||
api_key: ""
|
api_key: ""
|
||||||
ttl: 30d
|
ttl: 30d
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: INFO
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ Automatischer Upload von Aufnahmen auf einen [Plik](https://github.com/root-gg/p
|
|||||||
| Option | Beschreibung |
|
| Option | Beschreibung |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| **Plik-Upload** | Upload nach Aufnahme-Ende aktivieren |
|
| **Plik-Upload** | Upload nach Aufnahme-Ende aktivieren |
|
||||||
| **Nach Upload löschen** | Lokale Datei nach erfolgreichem Upload entfernen |
|
| **File nach Upload lokal löschen** | Lokale Datei nach erfolgreichem Upload entfernen |
|
||||||
|
|
||||||
### Links
|
### Links
|
||||||
|
|
||||||
@@ -50,36 +50,8 @@ Nach dem Upload werden zwei URLs generiert:
|
|||||||
- **Browser-URL**: `https://plik.example.com/#/?id=UPLOAD_ID` — Übersichtsseite mit In-Browser-Wiedergabe
|
- **Browser-URL**: `https://plik.example.com/#/?id=UPLOAD_ID` — Übersichtsseite mit In-Browser-Wiedergabe
|
||||||
- **Download-URL**: Direkter Datei-Download
|
- **Download-URL**: Direkter Datei-Download
|
||||||
|
|
||||||
Bei aktiviertem NTFY wird die Browser-URL in der Push-Nachricht mitgeschickt.
|
Die Plik-URL wird in der Datenbank gespeichert und im Dashboard bei den **Letzten Aufnahmen** als klickbarer Link angezeigt. Bei aktiviertem NTFY wird die Browser-URL zusätzlich in der Push-Nachricht mitgeschickt.
|
||||||
|
|
||||||
### Segment-Upload
|
### Segment-Upload
|
||||||
|
|
||||||
Bei Segment-Splitting werden alle Segmente einzeln hochgeladen. Die NTFY-Nachricht enthält die Anzahl der hochgeladenen Segmente.
|
Bei Segment-Splitting werden alle Segmente einzeln hochgeladen. Die NTFY-Nachricht enthält die Anzahl der hochgeladenen Segmente.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Euer-Radio Metadata-Monitor
|
|
||||||
|
|
||||||
Erkennt automatisch, ob eine bestimmte Show live sendet, indem der Stream-Titel via `ffprobe` abgefragt wird. Wenn das konfigurierte Pattern nicht mehr im Titel erscheint, wird die Aufnahme nach einer Karenzzeit gestoppt.
|
|
||||||
|
|
||||||
### Einrichtung
|
|
||||||
|
|
||||||
Pro Job im Web-UI:
|
|
||||||
|
|
||||||
| Option | Standard | Beschreibung |
|
|
||||||
|--------|----------|-------------|
|
|
||||||
| **Show-Pattern** | _(leer)_ | Text, der im Stream-Titel vorkommen muss (z.B. Sendungsname) |
|
|
||||||
| **Karenzzeit** | 5 Min. | Wie lange gewartet wird, nachdem das Pattern verschwunden ist |
|
|
||||||
| **Poll-Intervall** | 30 Sek. | Wie oft der Stream-Titel abgefragt wird |
|
|
||||||
|
|
||||||
### Funktionsweise
|
|
||||||
|
|
||||||
1. Der Monitor fragt regelmäßig den Stream-Titel per `ffprobe` ab
|
|
||||||
2. Solange das Pattern im Titel vorkommt, läuft die Aufnahme weiter
|
|
||||||
3. Wenn das Pattern verschwindet, startet die Karenzzeit
|
|
||||||
4. Taucht das Pattern innerhalb der Karenzzeit wieder auf, wird der Timer zurückgesetzt
|
|
||||||
5. Läuft die Karenzzeit ab, wird die Aufnahme gestoppt
|
|
||||||
|
|
||||||
### Anwendungsfall
|
|
||||||
|
|
||||||
Ideal für Streams mit wechselnden Shows (z.B. Internet-Radio), bei denen die Aufnahmedauer nicht vorher feststeht. Der Stream-Titel zeigt an, welche Show gerade läuft, und der Monitor erkennt automatisch das Ende.
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Sender
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Die Senderbibliothek verwaltet häufig genutzte Streams zentral. Einmal angelegte Sender können in Jobs und Schnellaufnahmen per Dropdown ausgewählt werden — die Stream-URL und der Typ werden automatisch übernommen.
|
||||||
|
|
||||||
|
## Sender anlegen
|
||||||
|
|
||||||
|
Im Web-UI unter **Sender → + Neuer Sender**.
|
||||||
|
|
||||||
|
| Feld | Pflicht | Beschreibung |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| **Name** | Ja | Sendername (z.B. `WDR 2`, `DLF Kultur`) |
|
||||||
|
| **Typ** | Nein | Stream-Typ: `auto` (Standard), RTMP, HLS, MP3, AAC, OGG, HTTP |
|
||||||
|
| **Stream-URL** | Ja | URL des Streams (z.B. `https://wdr-wdr2-live.icecast.wdr.de/wdr/wdr2/live/mp3/256/stream.mp3`) |
|
||||||
|
| **Beschreibung** | Nein | Freitext-Notiz (z.B. `Hauptstream, 256 kbit/s`) |
|
||||||
|
|
||||||
|
## Sender bearbeiten & löschen
|
||||||
|
|
||||||
|
In der Sender-Übersicht stehen pro Eintrag die Buttons **Bearbeiten** und **Löschen** zur Verfügung. Das Löschen eines Senders hat keinen Einfluss auf bereits existierende Jobs — die Stream-URL bleibt im Job erhalten.
|
||||||
|
|
||||||
|
## Verwendung in Jobs
|
||||||
|
|
||||||
|
Beim Erstellen oder Bearbeiten eines Jobs kann über das Dropdown **Senderbibliothek** ein gespeicherter Sender ausgewählt werden. Dabei werden automatisch übernommen:
|
||||||
|
|
||||||
|
- **Stream-URL** und **Typ** — die URL-Eingabe wird ausgeblendet
|
||||||
|
- **Name** — falls das Namensfeld noch leer ist
|
||||||
|
- **Beschreibung** — falls das Beschreibungsfeld noch leer ist
|
||||||
|
|
||||||
|
Alternativ bleibt die Option **URL manuell eingeben** bestehen.
|
||||||
|
|
||||||
|
## Verwendung in Schnellaufnahmen
|
||||||
|
|
||||||
|
Auch bei Schnellaufnahmen (Dashboard → + Schnellaufnahme) steht die Senderbibliothek als Dropdown zur Verfügung. Bei Auswahl werden URL, Typ und Name übernommen.
|
||||||