99import subprocess
1010import time
1111import uuid
12+ from dataclasses import dataclass
1213from datetime import datetime , timezone
1314from pathlib import Path
1415from urllib .parse import quote
1516
1617from google .cloud import storage
1718
19+ from gchat_notifier import format_bytes , format_duration , send_gchat_alert
1820from runtime import Tunnel , required_env
1921
2022
2325RTSP_USERINFO_RE = re .compile (r"rtsp://[^@\s]+@" , re .IGNORECASE )
2426
2527
28+ @dataclass
29+ class RecordingState :
30+ bucket_name : str = ""
31+ project_id : str = ""
32+ object_prefix : str = ""
33+ uploaded_segments : int = 0
34+ uploaded_bytes : int = 0
35+ monthly_limit_reached : bool = False
36+
37+
2638def safe_ffmpeg_diagnostic (stderr : str , limit : int = 2000 ) -> str :
2739 """Return a short FFmpeg diagnostic with RTSP credentials removed."""
2840 sanitized = RTSP_USERINFO_RE .sub ("rtsp://[REDACTED]@" , stderr )
@@ -41,6 +53,44 @@ def monthly_usage_bytes(client: storage.Client, bucket_name: str, prefix: str) -
4153 )
4254
4355
56+ def storage_console_url (state : RecordingState ) -> str :
57+ """Build a private Cloud Console link to this recording session."""
58+ if not state .bucket_name or not state .object_prefix :
59+ return ""
60+ path = quote (
61+ f"{ state .bucket_name } /{ state .object_prefix .rstrip ('/' )} " , safe = "/"
62+ )
63+ project = quote (state .project_id , safe = "" )
64+ return f"https://console.cloud.google.com/storage/browser/{ path } ?project={ project } "
65+
66+
67+ def recording_summary (
68+ state : RecordingState , elapsed_seconds : float , error_name : str | None = None
69+ ) -> str :
70+ """Build the structured end-of-session notification requested by the user."""
71+ recorded = state .uploaded_segments > 0
72+ title = "❌ *Session caméra interrompue*" if error_name else "✅ *Session caméra terminée*"
73+ lines = [
74+ title ,
75+ f"Erreur : { 'oui (' + error_name + ')' if error_name else 'non' } " ,
76+ f"Vidéo enregistrée : { 'oui' if recorded else 'non' } " ,
77+ f"Durée : { format_duration (elapsed_seconds )} " ,
78+ ]
79+ link = storage_console_url (state ) if recorded else ""
80+ if link :
81+ lines .append (f"Lien : <{ link } |ouvrir les vidéos>" )
82+ else :
83+ lines .append ("Lien : aucun" )
84+ if recorded :
85+ lines .append (
86+ f"Fichiers : { state .uploaded_segments } segment(s), "
87+ f"{ format_bytes (state .uploaded_bytes )} "
88+ )
89+ if state .monthly_limit_reached :
90+ lines .append ("Limite mensuelle de 30 Go atteinte." )
91+ return "\n " .join (lines )
92+
93+
4494def capture_segment (rtsp_url : str , destination : Path , duration : int ) -> None :
4595 command = [
4696 "ffmpeg" ,
@@ -93,14 +143,12 @@ def capture_segment(rtsp_url: str, destination: Path, duration: int) -> None:
93143 raise RuntimeError ("ffmpeg produced insufficient video data" )
94144
95145
96- def main () -> None :
97- signal .signal (signal .SIGTERM , _stop_on_signal )
98- signal .signal (signal .SIGINT , _stop_on_signal )
99-
146+ def record (state : RecordingState ) -> None :
100147 serial = required_env ("CAMERA_SERIAL" )
101148 username = required_env ("CAMERA_USERNAME" )
102149 password = required_env ("CAMERA_PASSWORD" )
103- bucket_name = required_env ("GCS_BUCKET" )
150+ state .bucket_name = required_env ("GCS_BUCKET" )
151+ state .project_id = os .environ .get ("PROJECT_ID" , "calving-monitor-20260819" )
104152 duration_seconds = int (os .environ .get ("RECORD_DURATION_SECONDS" , "600" ))
105153 segment_seconds = int (os .environ .get ("SEGMENT_SECONDS" , "60" ))
106154 monthly_limit = int (os .environ .get ("MONTHLY_RECORDING_LIMIT_BYTES" , "30000000000" ))
@@ -111,9 +159,14 @@ def main() -> None:
111159
112160 now = datetime .now (timezone .utc )
113161 monthly_prefix = f"raw/{ now :%Y/%m} /"
162+ session_id = f"{ now :%Y%m%dT%H%M%SZ} _{ uuid .uuid4 ().hex [:8 ]} "
163+ state .object_prefix = f"raw/{ now :%Y/%m/%d} /{ session_id } /"
114164 storage_client = storage .Client ()
115- used_bytes = monthly_usage_bytes (storage_client , bucket_name , monthly_prefix )
165+ used_bytes = monthly_usage_bytes (
166+ storage_client , state .bucket_name , monthly_prefix
167+ )
116168 if used_bytes >= monthly_limit :
169+ state .monthly_limit_reached = True
117170 LOG .warning ("Monthly recording limit already reached; recording skipped" )
118171 return
119172
@@ -122,10 +175,8 @@ def main() -> None:
122175 rtsp_url = (
123176 f"rtsp://{ escaped_user } :{ escaped_password } @127.0.0.1:{ local_port } { stream_path } "
124177 )
125- bucket = storage_client .bucket (bucket_name )
178+ bucket = storage_client .bucket (state . bucket_name )
126179 deadline = time .monotonic () + duration_seconds
127- uploaded = 0
128-
129180 with Tunnel (
130181 serial = serial ,
131182 local_port = local_port ,
@@ -143,7 +194,7 @@ def main() -> None:
143194 try :
144195 capture_segment (rtsp_url , local_path , current_duration )
145196 size = local_path .stat ().st_size
146- object_name = f"raw/ { timestamp :%Y/%m/%d } / { filename } "
197+ object_name = f"{ state . object_prefix } { filename } "
147198 blob = bucket .blob (object_name )
148199 blob .upload_from_filename (
149200 local_path ,
@@ -152,12 +203,40 @@ def main() -> None:
152203 timeout = 180 ,
153204 )
154205 used_bytes += size
155- uploaded += 1
206+ state .uploaded_segments += 1
207+ state .uploaded_bytes += size
156208 LOG .info ("Uploaded segment object=%s bytes=%d" , object_name , size )
157209 finally :
158210 local_path .unlink (missing_ok = True )
159211
160- LOG .info ("Recording job completed segments=%d monthly_bytes=%d" , uploaded , used_bytes )
212+ if used_bytes >= monthly_limit :
213+ state .monthly_limit_reached = True
214+ LOG .info (
215+ "Recording job completed segments=%d monthly_bytes=%d" ,
216+ state .uploaded_segments ,
217+ used_bytes ,
218+ )
219+
220+
221+ def main () -> None :
222+ started_at = time .monotonic ()
223+ state = RecordingState ()
224+ signal .signal (signal .SIGTERM , _stop_on_signal )
225+ signal .signal (signal .SIGINT , _stop_on_signal )
226+
227+ try :
228+ record (state )
229+ except BaseException as exc :
230+ send_gchat_alert (
231+ recording_summary (
232+ state ,
233+ time .monotonic () - started_at ,
234+ error_name = type (exc ).__name__ ,
235+ )
236+ )
237+ raise
238+
239+ send_gchat_alert (recording_summary (state , time .monotonic () - started_at ))
161240
162241
163242if __name__ == "__main__" :
0 commit comments