v3.0.0: Docker-Migration mit CI-Pipeline und neuen Features
Build Release Docker Image / build-arm64 (release) Successful in 1m0s
Build Release Docker Image / build-amd64 (release) Successful in 2m50s
Build Release Docker Image / publish-release-manifest (release) Successful in 7s

- Gitea CI-Workflows fuer Multi-Arch Docker Builds (amd64 + arm64)
- Beschreibungsfeld und mehrtaegige Zeitplanung fuer Jobs
- Benachrichtigungen auf Deutsch
- docker-compose nutzt Registry-Image

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Asmus
2026-09-16 09:40:40 +02:00
co-authored by Claude Opus 4.6
parent 9fa589789e
commit b446ce9b3e
9 changed files with 298 additions and 28 deletions
+24 -1
View File
@@ -97,6 +97,8 @@ class JobCreate(BaseModel):
schedule_days: str = "*"
schedule_start: str = ""
schedule_stop: str = ""
schedule_end_date: str = ""
description: str = ""
ntfy_enabled: bool = False
plik_enabled: bool = False
delete_after_upload: bool = False
@@ -136,6 +138,8 @@ async def list_jobs():
"schedule_days": j.schedule_days,
"schedule_start": j.schedule_start,
"schedule_stop": j.schedule_stop,
"schedule_end_date": j.schedule_end_date,
"description": j.description,
"ntfy_enabled": j.ntfy_enabled,
"plik_enabled": j.plik_enabled,
"delete_after_upload": j.delete_after_upload,
@@ -178,6 +182,7 @@ 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,
"ntfy_enabled": job.ntfy_enabled,
"plik_enabled": job.plik_enabled, "delete_after_upload": job.delete_after_upload,
"metadata_monitor_enabled": job.metadata_monitor_enabled,
@@ -226,11 +231,29 @@ async def start_job_recording(job_id: int):
job = session.query(Job).filter_by(id=job_id).first()
if not job:
raise HTTPException(404, "Job nicht gefunden")
duration = job.max_duration
if not duration and job.schedule_start and job.schedule_stop:
if job.schedule_end_date and job.schedule_date:
start_dt = datetime.datetime.strptime(f"{job.schedule_date} {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")
duration = max(0, int((end_dt - start_dt).total_seconds()))
else:
def _time_to_minutes(t: str) -> int:
parts = t.split(":")
return int(parts[0]) * 60 + int(parts[1])
start_min = _time_to_minutes(job.schedule_start)
stop_min = _time_to_minutes(job.schedule_stop)
if stop_min > start_min:
duration = (stop_min - start_min) * 60
else:
duration = (1440 - start_min + stop_min) * 60
job_data = {
"name": job.name, "stream_url": job.stream_url,
"stream_type": job.stream_type, "output_format": job.output_format,
"max_duration": job.max_duration, "extra_ffmpeg_args": job.extra_ffmpeg_args,
"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,
+3
View File
@@ -23,6 +23,9 @@ class Job(Base):
schedule_days = Column(String(50), default="*")
schedule_start = Column(String(5), default="")
schedule_stop = Column(String(5), default="")
schedule_end_date = Column(String(10), default="")
description = Column(Text, default="")
plik_enabled = Column(Boolean, default=False)
delete_after_upload = Column(Boolean, default=False)
+10 -4
View File
@@ -98,6 +98,9 @@ class RecordingManager:
ntfy_enabled = False
if rec_id and rec_id in self._rec_options:
ntfy_enabled = self._rec_options[rec_id].get("ntfy_enabled", False)
desc = self._rec_options[rec_id].get("description", "")
if desc:
message = f"{message}\n{desc}"
self._get_ntfy().notify(event, title, message, job_ntfy_enabled=ntfy_enabled, priority=priority)
def _log(self, level: str, message: str, job_name: str = None):
@@ -120,6 +123,7 @@ class RecordingManager:
extra_ffmpeg_args: str = "",
segment_duration: Optional[int] = None,
is_scheduled: bool = False,
description: str = "",
metadata_monitor_enabled: bool = False,
metadata_pattern: str = "",
metadata_grace_period: int = 300,
@@ -186,10 +190,11 @@ class RecordingManager:
"plik_enabled": plik_enabled,
"delete_after_upload": delete_after_upload,
"segment_mode": segment_mode,
"description": description,
}
self._log("INFO", f"Aufnahme gestartet: {name} [{stream_type}]", name)
self._notify("start", "Aufnahme gestartet", f"{name} [{stream_type}]", rec_id)
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(
@@ -251,9 +256,10 @@ class RecordingManager:
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}: {job_name}", job_name)
self._notify("stop", f"Aufnahme {status}", job_name, rec_id)
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
+35 -11
View File
@@ -85,12 +85,13 @@ class StreamScheduler:
finally:
session.close()
def _get_active_recording_for_job(self, job_id: int):
def _get_active_scheduled_recording_for_job(self, job_id: int):
session = get_session()
try:
rec = session.query(Recording).filter(
Recording.job_id == job_id,
Recording.status.in_(["running", "starting"]),
Recording.is_scheduled == True,
).first()
return rec.id if rec else None
finally:
@@ -114,7 +115,8 @@ class StreamScheduler:
"segment_duration": j.segment_duration,
"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_stop": j.schedule_stop, "schedule_end_date": j.schedule_end_date,
"description": j.description,
"metadata_monitor_enabled": j.metadata_monitor_enabled,
"metadata_pattern": j.metadata_pattern,
"metadata_grace_period": j.metadata_grace_period,
@@ -131,6 +133,8 @@ class StreamScheduler:
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
@@ -139,9 +143,21 @@ class StreamScheduler:
is_today = True
in_window = False
is_cross_midnight = False
if is_today:
if job["schedule_stop"]:
in_window = _in_time_window(now_time, job["schedule_start"], job["schedule_stop"])
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"])
@@ -153,12 +169,19 @@ class StreamScheduler:
if occ_key not in self._started_keys and not self._is_job_recording(job["id"]):
duration = job["max_duration"]
if not duration and job["schedule_stop"]:
start_min = _time_to_minutes(job["schedule_start"])
stop_min = _time_to_minutes(job["schedule_stop"])
if stop_min > start_min:
duration = (stop_min - start_min) * 60
if multi_day:
start_dt = datetime.datetime.strptime(
f"{job['schedule_date']} {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")
duration = max(0, int((end_dt - start_dt).total_seconds()))
else:
duration = (1440 - start_min + stop_min) * 60
start_min = _time_to_minutes(job["schedule_start"])
stop_min = _time_to_minutes(job["schedule_stop"])
if stop_min > start_min:
duration = (stop_min - start_min) * 60
else:
duration = (1440 - start_min + stop_min) * 60
self.recorder.start_recording(
job_id=job["id"],
@@ -170,6 +193,7 @@ class StreamScheduler:
extra_ffmpeg_args=job["extra_ffmpeg_args"] or "",
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,
@@ -180,8 +204,8 @@ class StreamScheduler:
)
self._started_keys.add(occ_key)
else:
if job["schedule_stop"] and self._is_job_recording(job["id"]):
rec_id = self._get_active_recording_for_job(job["id"])
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 rec_id:
self.recorder.stop_recording(rec_id)
self._started_keys.discard(occ_key)