Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3f68ace96 | ||
|
|
6d07b12015 | ||
|
|
50bb72803f | ||
|
|
a9a9a89cba | ||
|
|
ce1ccb91bb | ||
|
|
851ff086ac |
@@ -3,7 +3,7 @@ name: Build Dev Docker Image
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
branches: [dev]
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'README.md'
|
||||
@@ -27,7 +27,10 @@ jobs:
|
||||
|
||||
- name: Build and push amd64 image
|
||||
run: |
|
||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||
|
||||
docker build --pull --platform linux/amd64 \
|
||||
--build-arg APP_VERSION="dev-${SHORT_SHA}" \
|
||||
-t "${IMAGE}:dev-amd64" \
|
||||
-t "${IMAGE}:dev-${GITHUB_SHA}-amd64" \
|
||||
.
|
||||
@@ -47,7 +50,10 @@ jobs:
|
||||
|
||||
- name: Build and push arm64 image
|
||||
run: |
|
||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||
|
||||
docker build --pull --platform linux/arm64 \
|
||||
--build-arg APP_VERSION="dev-${SHORT_SHA}" \
|
||||
-t "${IMAGE}:dev-arm64" \
|
||||
-t "${IMAGE}:dev-${GITHUB_SHA}-arm64" \
|
||||
.
|
||||
|
||||
@@ -23,8 +23,10 @@ jobs:
|
||||
- name: Build and push amd64 release image
|
||||
run: |
|
||||
RELEASE_TAG="${GITHUB_REF_NAME}"
|
||||
VERSION="${RELEASE_TAG#v}"
|
||||
|
||||
docker build --pull --platform linux/amd64 \
|
||||
--build-arg APP_VERSION="${VERSION}" \
|
||||
-t "${IMAGE}:${RELEASE_TAG}-amd64" \
|
||||
.
|
||||
docker push "${IMAGE}:${RELEASE_TAG}-amd64"
|
||||
@@ -43,8 +45,10 @@ jobs:
|
||||
- name: Build and push arm64 release image
|
||||
run: |
|
||||
RELEASE_TAG="${GITHUB_REF_NAME}"
|
||||
VERSION="${RELEASE_TAG#v}"
|
||||
|
||||
docker build --pull --platform linux/arm64 \
|
||||
--build-arg APP_VERSION="${VERSION}" \
|
||||
-t "${IMAGE}:${RELEASE_TAG}-arm64" \
|
||||
.
|
||||
docker push "${IMAGE}:${RELEASE_TAG}-arm64"
|
||||
|
||||
@@ -11,10 +11,12 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY bin/backend/ /app/
|
||||
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
|
||||
|
||||
ARG APP_VERSION=dev
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
ENV DATA_DIR=/app/data
|
||||
ENV STATIC_DIR=/app/frontend/static
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
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>
|
||||
<br>
|
||||
|
||||

|
||||
|
||||
## Funktionen
|
||||
|
||||
- **Web-Interface** — Jobs planen, Aufnahmen steuern und verlängern im Browser
|
||||
|
||||
@@ -13,10 +13,12 @@ from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
from config import Config
|
||||
from models import init_db, get_session, Job, Recording as RecordingModel, LogEntry
|
||||
from models import init_db, get_session, Job, Station, Recording as RecordingModel, LogEntry
|
||||
from recorder import RecordingManager, format_elapsed
|
||||
from scheduler import StreamScheduler
|
||||
|
||||
APP_VERSION = os.environ.get("APP_VERSION", "dev")
|
||||
|
||||
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,11 +80,111 @@ 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 ---
|
||||
|
||||
class StationCreate(BaseModel):
|
||||
name: str
|
||||
stream_url: str
|
||||
stream_type: str = "auto"
|
||||
description: str = ""
|
||||
|
||||
|
||||
class StationUpdate(StationCreate):
|
||||
pass
|
||||
|
||||
|
||||
def _station_to_dict(station: Station) -> dict:
|
||||
return {
|
||||
"id": station.id,
|
||||
"name": station.name,
|
||||
"stream_url": station.stream_url,
|
||||
"stream_type": station.stream_type,
|
||||
"description": station.description,
|
||||
"created_at": station.created_at.isoformat() if station.created_at else None,
|
||||
"updated_at": station.updated_at.isoformat() if station.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _clean_station_data(data: StationCreate | StationUpdate) -> dict:
|
||||
values = data.model_dump()
|
||||
values["name"] = values["name"].strip()
|
||||
values["stream_url"] = values["stream_url"].strip()
|
||||
values["stream_type"] = (values.get("stream_type") or "auto").strip() or "auto"
|
||||
values["description"] = (values.get("description") or "").strip()
|
||||
if not values["name"] or not values["stream_url"]:
|
||||
raise HTTPException(400, "Name und URL sind Pflichtfelder")
|
||||
return values
|
||||
|
||||
|
||||
@app.get("/api/stations")
|
||||
async def list_stations():
|
||||
session = get_session()
|
||||
try:
|
||||
stations = session.query(Station).order_by(Station.name.collate("NOCASE"), Station.id).all()
|
||||
return [_station_to_dict(s) for s in stations]
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@app.post("/api/stations")
|
||||
async def create_station(data: StationCreate):
|
||||
values = _clean_station_data(data)
|
||||
session = get_session()
|
||||
try:
|
||||
station = Station(**values)
|
||||
session.add(station)
|
||||
session.commit()
|
||||
return {"id": station.id, "message": "Sender gespeichert"}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@app.get("/api/stations/{station_id}")
|
||||
async def get_station(station_id: int):
|
||||
session = get_session()
|
||||
try:
|
||||
station = session.query(Station).filter_by(id=station_id).first()
|
||||
if not station:
|
||||
raise HTTPException(404, "Sender nicht gefunden")
|
||||
return _station_to_dict(station)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@app.put("/api/stations/{station_id}")
|
||||
async def update_station(station_id: int, data: StationUpdate):
|
||||
values = _clean_station_data(data)
|
||||
session = get_session()
|
||||
try:
|
||||
station = session.query(Station).filter_by(id=station_id).first()
|
||||
if not station:
|
||||
raise HTTPException(404, "Sender nicht gefunden")
|
||||
for k, v in values.items():
|
||||
setattr(station, k, v)
|
||||
station.updated_at = datetime.datetime.utcnow()
|
||||
session.commit()
|
||||
return {"message": "Sender aktualisiert"}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@app.delete("/api/stations/{station_id}")
|
||||
async def delete_station(station_id: int):
|
||||
session = get_session()
|
||||
try:
|
||||
station = session.query(Station).filter_by(id=station_id).first()
|
||||
if not station:
|
||||
raise HTTPException(404, "Sender nicht gefunden")
|
||||
session.delete(station)
|
||||
session.commit()
|
||||
return {"message": "Sender gelöscht"}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
class JobCreate(BaseModel):
|
||||
name: str
|
||||
stream_url: str
|
||||
@@ -98,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):
|
||||
@@ -117,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(
|
||||
@@ -139,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,
|
||||
})
|
||||
@@ -182,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()
|
||||
@@ -254,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,
|
||||
@@ -310,6 +413,9 @@ async def get_recording(rec_id: int):
|
||||
|
||||
@app.post("/api/recordings/{rec_id}/stop")
|
||||
async def stop_recording(rec_id: int):
|
||||
status = recorder.get_status(rec_id)
|
||||
if status and status.get("job_id"):
|
||||
scheduler.suppress_current_occurrence(status["job_id"])
|
||||
recorder.stop_recording(rec_id)
|
||||
return {"message": "Aufnahme gestoppt"}
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ DEFAULT_CONFIG = {
|
||||
"api_key": "",
|
||||
"ttl": "30d",
|
||||
},
|
||||
"schedule": {
|
||||
"default_pre_buffer": 5,
|
||||
"default_post_buffer": 5,
|
||||
},
|
||||
"logging": {
|
||||
"level": "INFO",
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import datetime
|
||||
from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Text
|
||||
from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Text, text
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
|
||||
Base = declarative_base()
|
||||
@@ -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,15 +35,24 @@ 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)
|
||||
|
||||
|
||||
class Station(Base):
|
||||
__tablename__ = "stations"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
stream_url = Column(String(1000), nullable=False)
|
||||
stream_type = Column(String(20), default="auto")
|
||||
description = Column(Text, default="")
|
||||
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
|
||||
|
||||
|
||||
class Recording(Base):
|
||||
__tablename__ = "recordings"
|
||||
|
||||
@@ -59,6 +71,8 @@ class Recording(Base):
|
||||
segment_mode = Column(Boolean, default=False)
|
||||
is_scheduled = Column(Boolean, default=False)
|
||||
error_message = Column(Text, nullable=True)
|
||||
file_size = Column(Integer, nullable=True)
|
||||
plik_url = Column(String(1000), nullable=True)
|
||||
|
||||
|
||||
class LogEntry(Base):
|
||||
@@ -80,6 +94,23 @@ def init_db(db_path: str):
|
||||
_engine = create_engine(f"sqlite:///{db_path}", connect_args={"check_same_thread": False})
|
||||
Base.metadata.create_all(_engine)
|
||||
_SessionLocal = sessionmaker(bind=_engine)
|
||||
_migrate(_engine)
|
||||
|
||||
|
||||
def _migrate(engine):
|
||||
with engine.connect() as conn:
|
||||
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)
|
||||
@@ -5,10 +5,10 @@ import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Optional, Callable
|
||||
from typing import Callable
|
||||
|
||||
|
||||
def _parse_ttl_seconds(ttl: str) -> Optional[int]:
|
||||
def _parse_ttl_seconds(ttl: str) -> int | None:
|
||||
if not ttl:
|
||||
return None
|
||||
m = re.match(r"^(\d+)d$", ttl)
|
||||
@@ -28,17 +28,19 @@ def upload_file(
|
||||
plik_url: str,
|
||||
api_key: str = "",
|
||||
ttl: str = "30d",
|
||||
) -> Optional[dict]:
|
||||
"""Upload a file to Plik. Returns dict with 'browser_url' and 'download_url', or None on failure."""
|
||||
if not plik_url or not os.path.isfile(file_path):
|
||||
return None
|
||||
) -> dict | None:
|
||||
"""Upload a file to Plik. Returns dict with 'browser_url', 'download_url', or {'error': reason} on failure."""
|
||||
if not plik_url:
|
||||
return {"error": "Keine Plik-URL konfiguriert"}
|
||||
if not os.path.isfile(file_path):
|
||||
return {"error": f"Datei nicht gefunden: {file_path}"}
|
||||
if os.path.getsize(file_path) == 0:
|
||||
return None
|
||||
return {"error": "Datei ist leer"}
|
||||
|
||||
plik_base = plik_url.rstrip("/")
|
||||
filename = os.path.basename(file_path)
|
||||
|
||||
cmd = ["curl", "-sS", "--max-time", "7200"]
|
||||
cmd = ["curl", "-sS", "--fail-with-body", "--max-time", "7200"]
|
||||
if api_key:
|
||||
cmd += ["-H", f"X-PlikToken: {api_key}"]
|
||||
|
||||
@@ -53,13 +55,18 @@ def upload_file(
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=7200, stdin=subprocess.DEVNULL)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
err = result.stderr.strip() or result.stdout.strip() or f"curl exit code {result.returncode}"
|
||||
return {"error": f"curl-Fehler: {err[:500]}"}
|
||||
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
|
||||
if "error" in data:
|
||||
return {"error": f"Plik-Server: {data['error']}"}
|
||||
|
||||
upload_id = data.get("id", "")
|
||||
if not upload_id:
|
||||
return None
|
||||
return {"error": f"Keine Upload-ID in Antwort: {result.stdout[:300]}"}
|
||||
|
||||
browser_url = f"{plik_base}/#/?id={upload_id}"
|
||||
|
||||
@@ -83,9 +90,11 @@ def upload_file(
|
||||
if uid:
|
||||
return {"browser_url": f"{plik_base}/#/?id={uid}", "download_url": download_url}
|
||||
return {"browser_url": download_url, "download_url": download_url}
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
return {"error": f"Antwort nicht lesbar: {result.stdout[:300]}"}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"error": "Upload-Timeout (max. 2h)"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
class PlikUploader:
|
||||
@@ -95,7 +104,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 +117,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
|
||||
@@ -123,19 +133,23 @@ class PlikUploader:
|
||||
self._log("INFO", f"Plik-Upload: {os.path.basename(file_path)} [{job_name}]", job_name)
|
||||
result = upload_file(file_path, plik_url, api_key, ttl)
|
||||
|
||||
if result:
|
||||
if result and "error" in result:
|
||||
self._log("ERROR", f"Plik-Upload fehlgeschlagen: {result['error']} [{job_name}]", job_name)
|
||||
elif result and result.get("browser_url"):
|
||||
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:
|
||||
os.remove(file_path)
|
||||
self._log("INFO", f"Lokale Datei gelöscht: {file_path} [{job_name}]", job_name)
|
||||
else:
|
||||
self._log("ERROR", f"Plik-Upload fehlgeschlagen [{job_name}]", job_name)
|
||||
self._log("ERROR", f"Plik-Upload fehlgeschlagen (unbekannter Fehler) [{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
|
||||
|
||||
@@ -151,7 +165,9 @@ class PlikUploader:
|
||||
continue
|
||||
self._log("INFO", f"Plik Segment-Upload: {fname} [{job_name}]", job_name)
|
||||
result = upload_file(fpath, plik_url, api_key, ttl)
|
||||
if result:
|
||||
if result and "error" in result:
|
||||
self._log("ERROR", f"Plik Segment-Upload fehlgeschlagen: {fname} - {result['error']} [{job_name}]", job_name)
|
||||
elif result and result.get("browser_url"):
|
||||
last_browser_url = result["browser_url"]
|
||||
self._log("INFO", f"Plik Segment hochgeladen: {last_browser_url} [{job_name}]", job_name)
|
||||
uploaded += 1
|
||||
@@ -159,10 +175,13 @@ class PlikUploader:
|
||||
os.remove(fpath)
|
||||
self._log("INFO", f"Segment gelöscht: {fname} [{job_name}]", job_name)
|
||||
else:
|
||||
self._log("ERROR", f"Plik Segment-Upload fehlgeschlagen: {fname} [{job_name}]", job_name)
|
||||
self._log("ERROR", f"Plik Segment-Upload fehlgeschlagen: {fname} (unbekannter Fehler) [{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)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import glob as globmod
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
@@ -64,19 +65,10 @@ 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] = {}
|
||||
|
||||
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
|
||||
self._output_files: dict[int, list[str]] = {}
|
||||
|
||||
def _get_ntfy(self):
|
||||
if self._ntfy is None:
|
||||
@@ -124,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,
|
||||
@@ -173,6 +161,15 @@ class RecordingManager:
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
self._rec_options[rec_id] = {
|
||||
"ntfy_enabled": ntfy_enabled,
|
||||
"plik_enabled": plik_enabled,
|
||||
"delete_after_upload": delete_after_upload,
|
||||
"segment_mode": segment_mode,
|
||||
"description": description,
|
||||
}
|
||||
self._output_files[rec_id] = [output_file]
|
||||
|
||||
stop_event = threading.Event()
|
||||
self._stop_events[rec_id] = stop_event
|
||||
|
||||
@@ -185,27 +182,9 @@ class RecordingManager:
|
||||
self._threads[rec_id] = thread
|
||||
thread.start()
|
||||
|
||||
self._rec_options[rec_id] = {
|
||||
"ntfy_enabled": ntfy_enabled,
|
||||
"plik_enabled": plik_enabled,
|
||||
"delete_after_upload": delete_after_upload,
|
||||
"segment_mode": segment_mode,
|
||||
"description": description,
|
||||
}
|
||||
|
||||
self._log("INFO", f"Aufnahme gestartet: {name}", name)
|
||||
self._notify("start", "Aufnahme gestartet", name, rec_id)
|
||||
|
||||
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(
|
||||
@@ -225,74 +204,90 @@ class RecordingManager:
|
||||
retry_delay = self.config.get("recording", "retry_delay", default=5)
|
||||
retry = 0
|
||||
|
||||
while not stop_event.is_set():
|
||||
cmd = self._build_ffmpeg_cmd(
|
||||
stream_url, stream_type, output_file,
|
||||
extra_ffmpeg_args, segment_mode, segment_duration,
|
||||
)
|
||||
|
||||
try:
|
||||
log_path = os.path.join(output_dir, f".ffmpeg_{rec_id}.log")
|
||||
log_fh = open(log_path, "a")
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=log_fh,
|
||||
stderr=log_fh,
|
||||
stdin=subprocess.DEVNULL,
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
cmd = self._build_ffmpeg_cmd(
|
||||
stream_url, stream_type, output_file,
|
||||
extra_ffmpeg_args, segment_mode, segment_duration,
|
||||
)
|
||||
except Exception as e:
|
||||
self._update_recording(rec_id, status="error", error_message=str(e))
|
||||
self._log("ERROR", f"ffmpeg konnte nicht gestartet werden: {e}", job_name)
|
||||
self._notify("error", "Aufnahme fehlgeschlagen", f"{job_name}: {e}", rec_id, priority="high")
|
||||
return
|
||||
|
||||
self._processes[rec_id] = process
|
||||
self._update_recording(rec_id, status="running", pid=process.pid)
|
||||
try:
|
||||
log_path = os.path.join(output_dir, f".ffmpeg_{rec_id}.log")
|
||||
log_fh = open(log_path, "a")
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=log_fh,
|
||||
stderr=log_fh,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception as e:
|
||||
self._update_recording(rec_id, status="error", error_message=str(e),
|
||||
file_size=self._finalize_file_size(rec_id, segment_mode))
|
||||
self._log("ERROR", f"ffmpeg konnte nicht gestartet werden: {e}", job_name)
|
||||
self._notify("error", "Aufnahme fehlgeschlagen", f"{job_name}: {e}", rec_id, priority="high")
|
||||
return
|
||||
|
||||
process.wait()
|
||||
retcode = process.returncode
|
||||
log_fh.close()
|
||||
self._processes.pop(rec_id, None)
|
||||
self._processes[rec_id] = process
|
||||
self._update_recording(rec_id, status="running", pid=process.pid)
|
||||
|
||||
if stop_event.is_set() or retcode == 0:
|
||||
status = "completed" if retcode == 0 else "stopped"
|
||||
status_de = "abgeschlossen" if status == "completed" else "gestoppt"
|
||||
self._update_recording(rec_id, status=status)
|
||||
self._log("INFO", f"Aufnahme {status_de}: {job_name}", job_name)
|
||||
self._notify("stop", f"Aufnahme {status_de}", job_name, rec_id)
|
||||
self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
|
||||
return
|
||||
process.wait()
|
||||
retcode = process.returncode
|
||||
log_fh.close()
|
||||
self._processes.pop(rec_id, None)
|
||||
|
||||
if retcode in (-2, 130, 143, 255):
|
||||
self._update_recording(rec_id, status="stopped")
|
||||
self._log("INFO", f"Aufnahme gestoppt: {job_name}", job_name)
|
||||
self._notify("stop", "Aufnahme gestoppt", job_name, rec_id)
|
||||
self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
|
||||
return
|
||||
if stop_event.is_set() or retcode == 0:
|
||||
status = "completed" if retcode == 0 else "stopped"
|
||||
status_de = "abgeschlossen" if status == "completed" else "gestoppt"
|
||||
self._update_recording(rec_id, status=status,
|
||||
file_size=self._finalize_file_size(rec_id, segment_mode))
|
||||
self._log("INFO", f"Aufnahme {status_de}: {job_name}", job_name)
|
||||
self._notify("stop", f"Aufnahme {status_de}", job_name, rec_id)
|
||||
self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
|
||||
return
|
||||
|
||||
retry += 1
|
||||
if max_retries > 0 and retry >= max_retries:
|
||||
self._update_recording(rec_id, status="error",
|
||||
error_message=f"Max retries ({max_retries}) reached, exit code {retcode}")
|
||||
self._log("ERROR", f"Max. Versuche ({max_retries}) erreicht: {job_name}", job_name)
|
||||
self._notify("error", "Aufnahme fehlgeschlagen", f"{job_name}: Max. Versuche erreicht", rec_id, priority="high")
|
||||
return
|
||||
if retcode in (-2, 130, 143, 255):
|
||||
self._update_recording(rec_id, status="stopped",
|
||||
file_size=self._finalize_file_size(rec_id, segment_mode))
|
||||
self._log("INFO", f"Aufnahme gestoppt: {job_name}", job_name)
|
||||
self._notify("stop", "Aufnahme gestoppt", job_name, rec_id)
|
||||
self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
|
||||
return
|
||||
|
||||
self._log("WARN", f"Verbindung verloren (Code: {retcode}), Reconnect in {retry_delay}s... [{job_name}]", job_name)
|
||||
self._notify("error", "Stream abgerissen", f"{job_name}: Reconnect in {retry_delay}s...", rec_id)
|
||||
if stop_event.wait(timeout=retry_delay):
|
||||
self._update_recording(rec_id, status="stopped")
|
||||
return
|
||||
retry += 1
|
||||
if max_retries > 0 and retry >= max_retries:
|
||||
self._update_recording(rec_id, status="error",
|
||||
error_message=f"Max retries ({max_retries}) reached, exit code {retcode}",
|
||||
file_size=self._finalize_file_size(rec_id, segment_mode))
|
||||
self._log("ERROR", f"Max. Versuche ({max_retries}) erreicht: {job_name}", job_name)
|
||||
self._notify("error", "Aufnahme fehlgeschlagen", f"{job_name}: Max. Versuche erreicht", rec_id, priority="high")
|
||||
return
|
||||
|
||||
sname = safe_name(job_name)
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
ext = Path(output_file).suffix.lstrip(".")
|
||||
if segment_mode:
|
||||
output_file = os.path.join(output_dir, f"{sname}_{ts}_seg%03d.{ext}")
|
||||
else:
|
||||
output_file = os.path.join(output_dir, f"{sname}_{ts}.{ext}")
|
||||
self._log("WARN", f"Verbindung verloren (Code: {retcode}), Reconnect in {retry_delay}s... [{job_name}]", job_name)
|
||||
self._notify("error", "Stream abgerissen", f"{job_name}: Reconnect in {retry_delay}s...", rec_id)
|
||||
if stop_event.wait(timeout=retry_delay):
|
||||
self._update_recording(rec_id, status="stopped",
|
||||
file_size=self._finalize_file_size(rec_id, segment_mode))
|
||||
self._log("INFO", f"Aufnahme gestoppt: {job_name}", job_name)
|
||||
self._notify("stop", "Aufnahme gestoppt", job_name, rec_id)
|
||||
self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
|
||||
return
|
||||
|
||||
self._update_recording(rec_id, status="stopped")
|
||||
sname = safe_name(job_name)
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
ext = Path(output_file).suffix.lstrip(".")
|
||||
if segment_mode:
|
||||
output_file = os.path.join(output_dir, f"{sname}_{ts}_seg%03d.{ext}")
|
||||
else:
|
||||
output_file = os.path.join(output_dir, f"{sname}_{ts}.{ext}")
|
||||
self._output_files.setdefault(rec_id, []).append(output_file)
|
||||
|
||||
self._update_recording(rec_id, status="stopped",
|
||||
file_size=self._finalize_file_size(rec_id, segment_mode))
|
||||
self._log("INFO", f"Aufnahme gestoppt: {job_name}", job_name)
|
||||
self._notify("stop", "Aufnahme gestoppt", job_name, rec_id)
|
||||
self._handle_post_recording(rec_id, output_dir, output_file, job_name, segment_mode)
|
||||
finally:
|
||||
self._cleanup(rec_id)
|
||||
|
||||
def _build_ffmpeg_cmd(self, url, stream_type, output_file, extra_args, segment_mode, segment_duration):
|
||||
cmd = ["ffmpeg", "-nostdin", "-y", "-hide_banner", "-loglevel", "warning"]
|
||||
@@ -312,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()
|
||||
@@ -330,9 +322,10 @@ class RecordingManager:
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
|
||||
self._update_recording(rec_id, status="stopped")
|
||||
self._log("INFO", f"Aufnahme gestoppt (ID: {rec_id})")
|
||||
self._cleanup(rec_id)
|
||||
if not process and rec_id not in self._threads:
|
||||
self._update_recording(rec_id, status="stopped")
|
||||
self._log("INFO", f"Aufnahme gestoppt (ID: {rec_id})")
|
||||
self._cleanup(rec_id)
|
||||
return True
|
||||
|
||||
def extend_recording(self, rec_id: int, extra_seconds: int) -> bool:
|
||||
@@ -433,16 +426,11 @@ class RecordingManager:
|
||||
|
||||
total_size = 0
|
||||
segment_count = 0
|
||||
if rec.output_dir and os.path.isdir(rec.output_dir):
|
||||
for f in os.listdir(rec.output_dir):
|
||||
if f.startswith("."):
|
||||
continue
|
||||
fp = os.path.join(rec.output_dir, f)
|
||||
if os.path.isfile(fp):
|
||||
total_size += os.path.getsize(fp)
|
||||
segment_count += 1
|
||||
elif rec.output_file and os.path.isfile(rec.output_file):
|
||||
total_size = os.path.getsize(rec.output_file)
|
||||
if rec.file_size is not None:
|
||||
total_size = rec.file_size
|
||||
else:
|
||||
files = self._output_files.get(rec.id, [rec.output_file] if rec.output_file else [])
|
||||
total_size, segment_count = self._calc_recording_size(files, rec.segment_mode)
|
||||
|
||||
remaining = None
|
||||
if rec.scheduled_stop and rec.status == "running":
|
||||
@@ -472,8 +460,32 @@ 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]:
|
||||
total_size = 0
|
||||
segment_count = 0
|
||||
for of in output_files:
|
||||
if segment_mode:
|
||||
pattern = of.replace('%03d', '*')
|
||||
for f in globmod.glob(pattern):
|
||||
if os.path.isfile(f):
|
||||
total_size += os.path.getsize(f)
|
||||
segment_count += 1
|
||||
else:
|
||||
if os.path.isfile(of):
|
||||
total_size += os.path.getsize(of)
|
||||
segment_count += 1
|
||||
return total_size, segment_count
|
||||
|
||||
def _finalize_file_size(self, rec_id: int, segment_mode: bool) -> int:
|
||||
files = self._output_files.get(rec_id, [])
|
||||
if not files:
|
||||
return 0
|
||||
total_size, _ = self._calc_recording_size(files, segment_mode)
|
||||
return total_size
|
||||
|
||||
def _update_recording(self, rec_id: int, **kwargs):
|
||||
session = get_session()
|
||||
try:
|
||||
@@ -498,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):
|
||||
@@ -505,3 +518,4 @@ class RecordingManager:
|
||||
self._stop_events.pop(rec_id, None)
|
||||
self._threads.pop(rec_id, None)
|
||||
self._rec_options.pop(rec_id, None)
|
||||
self._output_files.pop(rec_id, None)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import datetime
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from sqlalchemy import or_
|
||||
from models import Job, Recording, get_session
|
||||
|
||||
DAY_MAP = {
|
||||
@@ -53,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
|
||||
@@ -75,6 +93,136 @@ 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()
|
||||
today_date = now.strftime("%Y-%m-%d")
|
||||
now_time = now.strftime("%H:%M")
|
||||
|
||||
occ_key = (
|
||||
f"{job['id']}|{job['schedule_once']}|"
|
||||
f"{today_date if not job['schedule_once'] else job['schedule_date']}|"
|
||||
f"{job['schedule_start']}"
|
||||
)
|
||||
|
||||
is_today = False
|
||||
multi_day = bool(job.get("schedule_end_date") and job["schedule_once"])
|
||||
|
||||
if job["schedule_once"]:
|
||||
if job["schedule_date"] == today_date:
|
||||
is_today = True
|
||||
else:
|
||||
if _matches_day(today_num, job["schedule_days"] or "*"):
|
||||
is_today = True
|
||||
|
||||
in_window = False
|
||||
is_cross_midnight = False
|
||||
if is_today:
|
||||
if multi_day:
|
||||
now_min = _time_to_minutes(now_time)
|
||||
start_min = _time_to_minutes(job["schedule_start"])
|
||||
in_window = now_min >= start_min
|
||||
elif job["schedule_stop"]:
|
||||
start_min = _time_to_minutes(job["schedule_start"])
|
||||
stop_min = _time_to_minutes(job["schedule_stop"])
|
||||
if stop_min <= start_min:
|
||||
is_cross_midnight = True
|
||||
now_min = _time_to_minutes(now_time)
|
||||
in_window = now_min >= start_min
|
||||
else:
|
||||
in_window = _in_time_window(now_time, job["schedule_start"], job["schedule_stop"])
|
||||
else:
|
||||
now_min = _time_to_minutes(now_time)
|
||||
start_min = _time_to_minutes(job["schedule_start"])
|
||||
in_window = now_min >= start_min
|
||||
elif job.get("schedule_stop"):
|
||||
start_min = _time_to_minutes(job["schedule_start"])
|
||||
stop_min = _time_to_minutes(job["schedule_stop"])
|
||||
if stop_min <= start_min and stop_min > 0:
|
||||
now_min = _time_to_minutes(now_time)
|
||||
if now_min < stop_min:
|
||||
yesterday = now - datetime.timedelta(days=1)
|
||||
was_yesterday = False
|
||||
if job["schedule_once"]:
|
||||
was_yesterday = job["schedule_date"] == yesterday.strftime("%Y-%m-%d")
|
||||
else:
|
||||
was_yesterday = _matches_day(yesterday.isoweekday(), job["schedule_days"] or "*")
|
||||
if was_yesterday:
|
||||
is_cross_midnight = True
|
||||
in_window = True
|
||||
|
||||
return {
|
||||
"occ_key": occ_key,
|
||||
"in_window": in_window,
|
||||
"multi_day": multi_day,
|
||||
"is_cross_midnight": is_cross_midnight,
|
||||
}
|
||||
|
||||
def suppress_current_occurrence(self, job_id: int) -> bool:
|
||||
session = get_session()
|
||||
try:
|
||||
j = session.query(Job).filter_by(id=job_id).first()
|
||||
if not j or not j.schedule_enabled or not j.schedule_start:
|
||||
return False
|
||||
job = {
|
||||
"id": j.id,
|
||||
"schedule_once": j.schedule_once,
|
||||
"schedule_date": j.schedule_date,
|
||||
"schedule_days": j.schedule_days,
|
||||
"schedule_start": j.schedule_start,
|
||||
"schedule_stop": j.schedule_stop,
|
||||
"schedule_end_date": j.schedule_end_date,
|
||||
"pre_buffer": j.pre_buffer or 0,
|
||||
"post_buffer": j.post_buffer or 0,
|
||||
}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
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(occ_key)
|
||||
return True
|
||||
|
||||
def _occurrence_start_utc(self, job: dict, now: datetime.datetime) -> datetime.datetime | None:
|
||||
date_text = job["schedule_date"] if job["schedule_once"] else now.strftime("%Y-%m-%d")
|
||||
try:
|
||||
local_start = datetime.datetime.strptime(
|
||||
f"{date_text} {job['schedule_start']}",
|
||||
"%Y-%m-%d %H:%M",
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
local_utc_offset = datetime.datetime.now() - datetime.datetime.utcnow()
|
||||
return local_start - local_utc_offset
|
||||
|
||||
def _has_recording_for_occurrence(self, job: dict, now: datetime.datetime) -> bool:
|
||||
occurrence_start = self._occurrence_start_utc(job, now)
|
||||
if occurrence_start is None:
|
||||
return False
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
return session.query(Recording).filter(
|
||||
Recording.job_id == job["id"],
|
||||
or_(
|
||||
Recording.started_at >= occurrence_start,
|
||||
Recording.stopped_at >= occurrence_start,
|
||||
),
|
||||
).first() is not None
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def _is_job_recording(self, job_id: int) -> bool:
|
||||
session = get_session()
|
||||
try:
|
||||
@@ -85,7 +233,7 @@ class StreamScheduler:
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def _get_active_scheduled_recording_for_job(self, job_id: int):
|
||||
def _get_active_scheduled_recording_for_job(self, job_id: int, *, respect_stop: bool = False):
|
||||
session = get_session()
|
||||
try:
|
||||
rec = session.query(Recording).filter(
|
||||
@@ -93,15 +241,16 @@ class StreamScheduler:
|
||||
Recording.status.in_(["running", "starting"]),
|
||||
Recording.is_scheduled == True,
|
||||
).first()
|
||||
return rec.id if rec else None
|
||||
if not rec:
|
||||
return None
|
||||
if respect_stop and rec.scheduled_stop and rec.scheduled_stop > datetime.datetime.utcnow():
|
||||
return None
|
||||
return rec.id
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def _tick(self):
|
||||
now = datetime.datetime.now()
|
||||
today_num = now.isoweekday()
|
||||
today_date = now.strftime("%Y-%m-%d")
|
||||
now_time = now.strftime("%H:%M")
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
@@ -116,11 +265,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,
|
||||
@@ -132,52 +278,28 @@ class StreamScheduler:
|
||||
if not job["schedule_start"]:
|
||||
continue
|
||||
|
||||
is_today = False
|
||||
multi_day = bool(job.get("schedule_end_date") and job["schedule_once"])
|
||||
|
||||
if job["schedule_once"]:
|
||||
if job["schedule_date"] == today_date:
|
||||
is_today = True
|
||||
else:
|
||||
if _matches_day(today_num, job["schedule_days"] or "*"):
|
||||
is_today = True
|
||||
|
||||
in_window = False
|
||||
is_cross_midnight = False
|
||||
if is_today:
|
||||
if multi_day:
|
||||
now_min = _time_to_minutes(now_time)
|
||||
start_min = _time_to_minutes(job["schedule_start"])
|
||||
in_window = now_min >= start_min
|
||||
elif job["schedule_stop"]:
|
||||
start_min = _time_to_minutes(job["schedule_start"])
|
||||
stop_min = _time_to_minutes(job["schedule_stop"])
|
||||
if stop_min <= start_min:
|
||||
is_cross_midnight = True
|
||||
now_min = _time_to_minutes(now_time)
|
||||
in_window = now_min >= start_min
|
||||
else:
|
||||
in_window = _in_time_window(now_time, job["schedule_start"], job["schedule_stop"])
|
||||
else:
|
||||
now_min = _time_to_minutes(now_time)
|
||||
start_min = _time_to_minutes(job["schedule_start"])
|
||||
in_window = now_min >= start_min
|
||||
|
||||
occ_key = f"{job['id']}|{job['schedule_once']}|{today_date if not job['schedule_once'] else job['schedule_date']}|{job['schedule_start']}"
|
||||
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"]
|
||||
|
||||
if in_window:
|
||||
if occ_key not in self._started_keys and not self._is_job_recording(job["id"]):
|
||||
if (
|
||||
occ_key not in self._started_keys
|
||||
and not self._is_job_recording(job["id"])
|
||||
and not self._has_recording_for_occurrence(job, now)
|
||||
):
|
||||
duration = job["max_duration"]
|
||||
if not duration and job["schedule_stop"]:
|
||||
if multi_day:
|
||||
if 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:
|
||||
@@ -194,18 +316,21 @@ 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),
|
||||
)
|
||||
self._started_keys.add(occ_key)
|
||||
else:
|
||||
if not multi_day and not is_cross_midnight and job["schedule_stop"] and self._is_job_recording(job["id"]):
|
||||
rec_id = self._get_active_scheduled_recording_for_job(job["id"])
|
||||
if (
|
||||
not state["multi_day"]
|
||||
and not state["is_cross_midnight"]
|
||||
and eff_job["schedule_stop"]
|
||||
and self._is_job_recording(job["id"])
|
||||
):
|
||||
rec_id = self._get_active_scheduled_recording_for_job(
|
||||
job["id"], respect_stop=True,
|
||||
)
|
||||
if rec_id:
|
||||
self.recorder.stop_recording(rec_id)
|
||||
self._started_keys.discard(occ_key)
|
||||
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -3,7 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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>
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
@@ -30,6 +31,7 @@
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 52px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -53,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%;
|
||||
@@ -306,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; }
|
||||
@@ -338,15 +384,62 @@
|
||||
padding-bottom: 8px;
|
||||
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;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
text-align: center;
|
||||
padding: 12px 16px;
|
||||
background: rgba(15, 17, 23, 0.96);
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.site-footer a {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.site-footer a:last-child {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header>
|
||||
<h1><span class="rec-dot" id="recDot"></span> stream-recorder</h1>
|
||||
<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>
|
||||
<button class="tab" onclick="switchTab('logs')">Logs</button>
|
||||
<button class="tab" onclick="switchTab('settings')">Einstellungen</button>
|
||||
</div>
|
||||
@@ -373,6 +466,15 @@
|
||||
<div id="jobsList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Stations -->
|
||||
<div class="panel" id="panel-stations">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
||||
<p class="section-title" style="margin:0">Senderbibliothek</p>
|
||||
<button class="btn btn-primary btn-sm" onclick="openStationModal()">+ Neuer Sender</button>
|
||||
</div>
|
||||
<div id="stationsList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Logs -->
|
||||
<div class="panel" id="panel-logs">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
||||
@@ -402,6 +504,12 @@
|
||||
<div class="modal-overlay" id="quickRecordModal">
|
||||
<div class="modal">
|
||||
<div class="modal-title">Schnellaufnahme</div>
|
||||
<div class="form-group">
|
||||
<label>Senderbibliothek</label>
|
||||
<select id="qr-station" onchange="applyStationToQuickRecord()">
|
||||
<option value="">URL manuell eingeben</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Stream-URL *</label>
|
||||
<input type="text" id="qr-url" placeholder="rtmp://... oder https://...m3u8">
|
||||
@@ -443,6 +551,12 @@
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group full">
|
||||
<label>Senderbibliothek</label>
|
||||
<select id="job-station" onchange="applyStationToJob()">
|
||||
<option value="">URL manuell eingeben</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group full" id="jobUrlGroup">
|
||||
<label>Stream-URL *</label>
|
||||
<input type="text" id="job-url" placeholder="rtmp://... oder https://...m3u8">
|
||||
</div>
|
||||
@@ -522,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>
|
||||
|
||||
@@ -541,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>
|
||||
@@ -574,6 +684,44 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Station Modal -->
|
||||
<div class="modal-overlay" id="stationModal">
|
||||
<div class="modal">
|
||||
<div class="modal-title" id="stationModalTitle">Neuer Sender</div>
|
||||
<input type="hidden" id="station-edit-id">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>Name *</label>
|
||||
<input type="text" id="station-name" placeholder="Sendername">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Typ</label>
|
||||
<select id="station-type">
|
||||
<option value="auto">Automatisch</option>
|
||||
<option value="rtmp">RTMP</option>
|
||||
<option value="hls">HLS</option>
|
||||
<option value="mp3">MP3</option>
|
||||
<option value="aac">AAC</option>
|
||||
<option value="ogg">OGG</option>
|
||||
<option value="http">HTTP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Stream-URL *</label>
|
||||
<input type="text" id="station-url" placeholder="rtmp://... oder https://...m3u8">
|
||||
</div>
|
||||
<div class="form-group full">
|
||||
<label>Beschreibung (optional)</label>
|
||||
<textarea id="station-description" rows="3" placeholder="z.B. Hauptstream, Wochenendprogramm..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" onclick="closeModal('stationModal')">Abbrechen</button>
|
||||
<button class="btn btn-primary" onclick="saveStation()">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extend Modal -->
|
||||
<div class="modal-overlay" id="extendModal">
|
||||
<div class="modal" style="max-width:400px">
|
||||
@@ -600,8 +748,23 @@
|
||||
</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 = [];
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(API + path, {
|
||||
@@ -615,17 +778,31 @@ 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');
|
||||
const tabLabel = {
|
||||
dashboard: 'dash',
|
||||
jobs: 'jobs',
|
||||
stations: 'sender',
|
||||
logs: 'logs',
|
||||
settings: 'einst',
|
||||
}[name] || name;
|
||||
document.querySelectorAll('.tab').forEach(t => {
|
||||
if (t.textContent.toLowerCase().includes(name === 'dashboard' ? 'dash' : name === 'settings' ? 'einst' : name))
|
||||
if (t.textContent.toLowerCase().includes(tabLabel))
|
||||
t.classList.add('active');
|
||||
});
|
||||
if (name === 'logs') loadLogs();
|
||||
if (name === 'jobs') loadJobs();
|
||||
if (name === 'stations') loadStations();
|
||||
if (name === 'settings') loadSettings();
|
||||
}
|
||||
|
||||
@@ -635,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) {
|
||||
@@ -660,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>' : ''}
|
||||
@@ -689,21 +862,220 @@ 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
|
||||
async function loadStationOptions() {
|
||||
try {
|
||||
stationsCache = await api('/api/stations');
|
||||
populateStationSelects();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function populateStationSelects() {
|
||||
['qr-station', 'job-station'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
const selected = el.value;
|
||||
el.innerHTML = '<option value="">URL manuell eingeben</option>' + stationsCache.map(s =>
|
||||
`<option value="${s.id}">${esc(s.name)}${s.description ? ' - ' + esc(s.description) : ''}</option>`
|
||||
).join('');
|
||||
if (stationsCache.some(s => String(s.id) === selected)) el.value = selected;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadStations() {
|
||||
try {
|
||||
await loadStationOptions();
|
||||
const el = document.getElementById('stationsList');
|
||||
if (!stationsCache.length) {
|
||||
el.innerHTML = '<div class="empty"><div class="empty-icon">📻</div>Keine Sender gespeichert<br><button class="btn btn-primary" style="margin-top:12px" onclick="openStationModal()">Ersten Sender anlegen</button></div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = stationsCache.map(s => `
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
${esc(s.name)}
|
||||
<span class="badge badge-scheduled">${esc(s.stream_type || 'auto')}</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-sm" onclick="openStationModal(${s.id})">Bearbeiten</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteStation(${s.id})">Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
${s.description ? `<div class="meta" style="margin-bottom:4px"><span class="meta-item" style="color:var(--text)">${esc(s.description)}</span></div>` : ''}
|
||||
<div class="meta">
|
||||
<span class="meta-item">${esc(s.stream_url)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function openStationModal(id = null) {
|
||||
const station = id ? stationsCache.find(s => s.id === id) : null;
|
||||
document.getElementById('station-edit-id').value = station ? station.id : '';
|
||||
document.getElementById('stationModalTitle').textContent = station ? 'Sender bearbeiten' : 'Neuer Sender';
|
||||
document.getElementById('station-name').value = station ? station.name : '';
|
||||
document.getElementById('station-url').value = station ? station.stream_url : '';
|
||||
document.getElementById('station-type').value = station ? station.stream_type || 'auto' : 'auto';
|
||||
document.getElementById('station-description').value = station ? station.description || '' : '';
|
||||
openModal('stationModal');
|
||||
}
|
||||
|
||||
async function saveStation() {
|
||||
const editId = document.getElementById('station-edit-id').value;
|
||||
const data = {
|
||||
name: document.getElementById('station-name').value,
|
||||
stream_url: document.getElementById('station-url').value,
|
||||
stream_type: document.getElementById('station-type').value,
|
||||
description: document.getElementById('station-description').value,
|
||||
};
|
||||
if (!data.name || !data.stream_url) {
|
||||
alert('Name und URL sind Pflichtfelder');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editId) {
|
||||
await api(`/api/stations/${editId}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
} else {
|
||||
await api('/api/stations', { method: 'POST', body: JSON.stringify(data) });
|
||||
}
|
||||
closeModal('stationModal');
|
||||
loadStations();
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteStation(id) {
|
||||
if (!confirm('Sender wirklich löschen?')) return;
|
||||
await api(`/api/stations/${id}`, { method: 'DELETE' });
|
||||
loadStations();
|
||||
}
|
||||
|
||||
function applyStationToQuickRecord() {
|
||||
const id = document.getElementById('qr-station').value;
|
||||
const station = stationsCache.find(s => String(s.id) === id);
|
||||
if (!station) return;
|
||||
document.getElementById('qr-url').value = station.stream_url;
|
||||
document.getElementById('qr-type').value = station.stream_type || 'auto';
|
||||
if (!document.getElementById('qr-name').value) {
|
||||
document.getElementById('qr-name').value = station.name;
|
||||
}
|
||||
}
|
||||
|
||||
function applyStationToJob() {
|
||||
const id = document.getElementById('job-station').value;
|
||||
const station = stationsCache.find(s => String(s.id) === id);
|
||||
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';
|
||||
if (!document.getElementById('job-name').value) {
|
||||
document.getElementById('job-name').value = station.name;
|
||||
}
|
||||
if (!document.getElementById('job-description').value && station.description) {
|
||||
document.getElementById('job-description').value = station.description;
|
||||
}
|
||||
}
|
||||
|
||||
// Jobs
|
||||
@@ -732,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('');
|
||||
@@ -748,8 +1119,11 @@ async function loadJobs() {
|
||||
}
|
||||
|
||||
function openJobModal(job = null) {
|
||||
loadStationOptions();
|
||||
document.getElementById('job-edit-id').value = job ? job.id : '';
|
||||
document.getElementById('jobModalTitle').textContent = job ? 'Job bearbeiten' : 'Neuer Job';
|
||||
document.getElementById('job-station').value = 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';
|
||||
@@ -768,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');
|
||||
}
|
||||
|
||||
@@ -803,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) {
|
||||
@@ -866,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);
|
||||
@@ -881,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;
|
||||
}
|
||||
@@ -903,18 +1283,35 @@ 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();
|
||||
document.getElementById('qr-station').value = '';
|
||||
document.getElementById('qr-url').value = '';
|
||||
document.getElementById('qr-name').value = '';
|
||||
document.getElementById('qr-type').value = 'auto';
|
||||
@@ -1033,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">
|
||||
@@ -1081,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,
|
||||
@@ -1120,12 +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 style="text-align:center;padding:32px 16px 16px;color:var(--text-dim);font-size:0.75rem">
|
||||
stream-recorder © <a href="https://www.patrick-asmus.de" target="_blank" rel="noopener" style="color:var(--text-dim)">Patrick Asmus</a> · <a href="https://git.techniverse.net/scriptos/stream-recorder" target="_blank" rel="noopener" style="color:var(--accent)">Source</a>
|
||||
<footer class="site-footer">
|
||||
<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>
|
||||
|
||||
|
After Width: | Height: | Size: 135 KiB |
@@ -4,16 +4,16 @@ services:
|
||||
container_name: stream-recorder
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${APP_PORT_BIND:-8484}:8484"
|
||||
- "8484:8484"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
# Optional: Recordings auf separatem Pfad (dann download_path in Einstellungen anpassen)
|
||||
# - /mnt/storage/recordings:/recordings
|
||||
networks:
|
||||
stream-recorder_net:
|
||||
ipv4_address: ${APP_IPV4_ADDRESS:-172.16.84.10}
|
||||
ipv4_address: 172.16.84.10
|
||||
environment:
|
||||
TZ: ${TZ:-Europe/Berlin}
|
||||
TZ: Europe/Berlin
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8484/api/health"]
|
||||
interval: 30s
|
||||
@@ -23,10 +23,10 @@ services:
|
||||
|
||||
networks:
|
||||
stream-recorder_net:
|
||||
name: ${DOCKER_NETWORK_NAME:-stream-recorder.dockernetwork.local}
|
||||
name: stream-recorder.dockernetwork.local
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: ${DOCKER_NETWORK_SUBNET:-172.16.84.0/24}
|
||||
gateway: ${DOCKER_NETWORK_GATEWAY:-172.16.84.1}
|
||||
ip_range: ${DOCKER_NETWORK_IP_RANGE:-172.16.84.128/25}
|
||||
- subnet: 172.16.84.0/24
|
||||
gateway: 172.16.84.1
|
||||
ip_range: 172.16.84.128/25
|
||||
|
||||
@@ -23,6 +23,16 @@ Alle Funktionen des Web-UI sind auch per REST-API verfügbar. Basis-URL: `http:/
|
||||
| `DELETE` | `/api/jobs/:id` | Job löschen |
|
||||
| `POST` | `/api/jobs/:id/record` | Job-Aufnahme starten |
|
||||
|
||||
### Senderbibliothek
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|-------------|
|
||||
| `GET` | `/api/stations` | Alle gespeicherten Sender auflisten |
|
||||
| `POST` | `/api/stations` | Neuen Sender erstellen |
|
||||
| `GET` | `/api/stations/:id` | Sender-Details abrufen |
|
||||
| `PUT` | `/api/stations/:id` | Sender aktualisieren |
|
||||
| `DELETE` | `/api/stations/:id` | Sender löschen |
|
||||
|
||||
### Schnellaufnahme
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|
||||
|
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
|
||||
|
||||

|
||||
|
||||
Das Dashboard zeigt alle laufenden Aufnahmen mit:
|
||||
- Laufzeit
|
||||
- Dateigröße
|
||||
- Verbleibende Zeit (bei Zeitlimit)
|
||||
- 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
|
||||
|
||||

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

|
||||
|
||||
Jobs sind vorkonfigurierte Aufnahme-Vorlagen. Jeder Job definiert einen Stream, optional einen Zeitplan und Plugin-Einstellungen.
|
||||
|
||||
## Job erstellen
|
||||
@@ -17,6 +19,8 @@ Im Web-UI unter **Jobs → + Neuer Job**.
|
||||
|
||||
| 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 |
|
||||
| **Ausgabeformat** | Dateiendung. Leer = automatisch (mp4 für Video, mp3 für Audio) |
|
||||
| **Max. Dauer** | Zeitlimit in Minuten. Entfällt bei Zeitplan mit Endzeit |
|
||||
@@ -30,7 +34,8 @@ Aktiviert automatische Aufnahmen zu festgelegten Zeiten.
|
||||
| Feld | Beschreibung |
|
||||
|------|-------------|
|
||||
| **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` |
|
||||
| **Startzeit** | Aufnahme-Beginn (HH:MM) |
|
||||
| **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`
|
||||
|
||||
### 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
|
||||
|
||||
Jobs können auch manuell über den **Aufnehmen**-Button gestartet werden, unabhängig vom Zeitplan.
|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
@@ -13,9 +15,16 @@ Beim ersten Start wird die Datei automatisch mit Standardwerten erstellt. Eine V
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|-----------|----------|-------------|
|
||||
| `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 |
|
||||
|
||||
### Server
|
||||
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|-----------|----------|-------------|
|
||||
| `server.host` | `0.0.0.0` | Bind-Adresse des Webservers |
|
||||
| `server.port` | `8484` | Port des Webservers |
|
||||
|
||||
### Authentifizierung
|
||||
|
||||
| Parameter | Standard | Beschreibung |
|
||||
@@ -24,6 +33,15 @@ Beim ersten Start wird die Datei automatisch mit Standardwerten erstellt. Eine V
|
||||
| `auth.username` | `admin` | Benutzername |
|
||||
| `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
|
||||
|
||||
| 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.ttl` | `30d` | Aufbewahrungsdauer der Uploads (`30d`, `12h`, etc.) |
|
||||
|
||||
### Logging
|
||||
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|-----------|----------|-------------|
|
||||
| `logging.level` | `INFO` | Log-Level: `DEBUG`, `INFO`, `WARN`, `ERROR` |
|
||||
|
||||
## Beispiel
|
||||
|
||||
```yaml
|
||||
@@ -48,11 +72,19 @@ recording:
|
||||
max_retries: 5
|
||||
retry_delay: 5
|
||||
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8484
|
||||
|
||||
auth:
|
||||
enabled: true
|
||||
username: admin
|
||||
password: mein-passwort
|
||||
|
||||
schedule:
|
||||
default_pre_buffer: 5
|
||||
default_post_buffer: 5
|
||||
|
||||
ntfy:
|
||||
url: https://ntfy.sh/stream-recorder
|
||||
token: ""
|
||||
@@ -62,4 +94,7 @@ plik:
|
||||
url: https://plik.example.com
|
||||
api_key: ""
|
||||
ttl: 30d
|
||||
|
||||
logging:
|
||||
level: INFO
|
||||
```
|
||||
|
||||
@@ -42,7 +42,7 @@ Automatischer Upload von Aufnahmen auf einen [Plik](https://github.com/root-gg/p
|
||||
| Option | Beschreibung |
|
||||
|--------|-------------|
|
||||
| **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
|
||||
|
||||
@@ -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
|
||||
- **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
|
||||
|
||||
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.
|
||||