diff --git a/.gitea/workflows/docker-dev.yaml b/.gitea/workflows/docker-dev.yaml new file mode 100644 index 0000000..bdb1398 --- /dev/null +++ b/.gitea/workflows/docker-dev.yaml @@ -0,0 +1,76 @@ +name: Build Dev Docker Image + +on: + workflow_dispatch: + push: + branches: [main, dev] + paths-ignore: + - 'docs/**' + - 'README.md' + - 'LICENSE' + +env: + REGISTRY: git.techniverse.net + IMAGE: git.techniverse.net/scriptos/stream-recorder + +jobs: + build-amd64: + runs-on: linux-amd64 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Login to Gitea Container Registry + run: echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" -u "${GITHUB_ACTOR}" --password-stdin + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build and push amd64 image + run: | + docker build --pull --platform linux/amd64 \ + -t "${IMAGE}:dev-amd64" \ + -t "${IMAGE}:dev-${GITHUB_SHA}-amd64" \ + . + docker push "${IMAGE}:dev-amd64" + docker push "${IMAGE}:dev-${GITHUB_SHA}-amd64" + + build-arm64: + runs-on: linux-arm64 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Login to Gitea Container Registry + run: echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" -u "${GITHUB_ACTOR}" --password-stdin + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build and push arm64 image + run: | + docker build --pull --platform linux/arm64 \ + -t "${IMAGE}:dev-arm64" \ + -t "${IMAGE}:dev-${GITHUB_SHA}-arm64" \ + . + docker push "${IMAGE}:dev-arm64" + docker push "${IMAGE}:dev-${GITHUB_SHA}-arm64" + + publish-dev-manifest: + runs-on: linux-amd64 + needs: [build-amd64, build-arm64] + steps: + - name: Login to Gitea Container Registry + run: echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" -u "${GITHUB_ACTOR}" --password-stdin + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + + - name: Publish dev manifests + run: | + docker manifest create "${IMAGE}:dev-latest" \ + --amend "${IMAGE}:dev-amd64" \ + --amend "${IMAGE}:dev-arm64" + docker manifest push "${IMAGE}:dev-latest" + + docker manifest create "${IMAGE}:dev-${GITHUB_SHA}" \ + --amend "${IMAGE}:dev-${GITHUB_SHA}-amd64" \ + --amend "${IMAGE}:dev-${GITHUB_SHA}-arm64" + docker manifest push "${IMAGE}:dev-${GITHUB_SHA}" diff --git a/.gitea/workflows/docker-release.yaml b/.gitea/workflows/docker-release.yaml new file mode 100644 index 0000000..068c98c --- /dev/null +++ b/.gitea/workflows/docker-release.yaml @@ -0,0 +1,87 @@ +name: Build Release Docker Image + +on: + release: + types: [published] + +env: + REGISTRY: git.techniverse.net + IMAGE: git.techniverse.net/scriptos/stream-recorder + +jobs: + build-amd64: + runs-on: linux-amd64 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Login to Gitea Container Registry + run: echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" -u "${GITHUB_ACTOR}" --password-stdin + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build and push amd64 release image + run: | + RELEASE_TAG="${GITHUB_REF_NAME}" + VERSION="${RELEASE_TAG#v}" + + docker build --pull --platform linux/amd64 \ + -t "${IMAGE}:${RELEASE_TAG}-amd64" \ + -t "${IMAGE}:${VERSION}-amd64" \ + . + docker push "${IMAGE}:${RELEASE_TAG}-amd64" + docker push "${IMAGE}:${VERSION}-amd64" + + build-arm64: + runs-on: linux-arm64 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Login to Gitea Container Registry + run: echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" -u "${GITHUB_ACTOR}" --password-stdin + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build and push arm64 release image + run: | + RELEASE_TAG="${GITHUB_REF_NAME}" + VERSION="${RELEASE_TAG#v}" + + docker build --pull --platform linux/arm64 \ + -t "${IMAGE}:${RELEASE_TAG}-arm64" \ + -t "${IMAGE}:${VERSION}-arm64" \ + . + docker push "${IMAGE}:${RELEASE_TAG}-arm64" + docker push "${IMAGE}:${VERSION}-arm64" + + publish-release-manifest: + runs-on: linux-amd64 + needs: [build-amd64, build-arm64] + steps: + - name: Login to Gitea Container Registry + run: echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" -u "${GITHUB_ACTOR}" --password-stdin + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + + - name: Publish release manifests + run: | + RELEASE_TAG="${GITHUB_REF_NAME}" + VERSION="${RELEASE_TAG#v}" + + docker manifest create "${IMAGE}:${RELEASE_TAG}" \ + --amend "${IMAGE}:${RELEASE_TAG}-amd64" \ + --amend "${IMAGE}:${RELEASE_TAG}-arm64" + docker manifest push "${IMAGE}:${RELEASE_TAG}" + + if [ "${VERSION}" != "${RELEASE_TAG}" ]; then + docker manifest create "${IMAGE}:${VERSION}" \ + --amend "${IMAGE}:${VERSION}-amd64" \ + --amend "${IMAGE}:${VERSION}-arm64" + docker manifest push "${IMAGE}:${VERSION}" + fi + + docker manifest create "${IMAGE}:latest" \ + --amend "${IMAGE}:${RELEASE_TAG}-amd64" \ + --amend "${IMAGE}:${RELEASE_TAG}-arm64" + docker manifest push "${IMAGE}:latest" diff --git a/README.md b/README.md index ddae64f..62f7354 100644 --- a/README.md +++ b/README.md @@ -49,12 +49,28 @@ docker compose up -d Das Web-UI ist unter `http://localhost:8484` erreichbar. Die Konfiguration wird beim ersten Start automatisch in `data/config.yml` erstellt. +## Container Image + +Release-Images werden automatisch in der Gitea Container Registry veröffentlicht: + +```bash +docker pull git.techniverse.net/scriptos/stream-recorder:latest +docker pull git.techniverse.net/scriptos/stream-recorder:v3.0.0 +docker pull git.techniverse.net/scriptos/stream-recorder:3.0.0 +``` + +Dev-Images entstehen bei Pushes auf `main` oder `dev`: + +```bash +docker pull git.techniverse.net/scriptos/stream-recorder:dev-latest +``` + ## Docker Compose ```yaml services: stream-recorder: - image: stream-recorder:latest + image: git.techniverse.net/scriptos/stream-recorder:latest container_name: stream-recorder restart: unless-stopped ports: diff --git a/bin/backend/app.py b/bin/backend/app.py index 1245bbc..4cbac4e 100644 --- a/bin/backend/app.py +++ b/bin/backend/app.py @@ -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, diff --git a/bin/backend/models.py b/bin/backend/models.py index 4066909..305e385 100644 --- a/bin/backend/models.py +++ b/bin/backend/models.py @@ -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) diff --git a/bin/backend/recorder.py b/bin/backend/recorder.py index 08a2663..edec349 100644 --- a/bin/backend/recorder.py +++ b/bin/backend/recorder.py @@ -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 diff --git a/bin/backend/scheduler.py b/bin/backend/scheduler.py index f4b0886..fc855e7 100644 --- a/bin/backend/scheduler.py +++ b/bin/backend/scheduler.py @@ -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) diff --git a/bin/frontend/static/index.html b/bin/frontend/static/index.html index 045d05d..9dfba34 100644 --- a/bin/frontend/static/index.html +++ b/bin/frontend/static/index.html @@ -450,6 +450,10 @@ +