@@ -17,6 +17,7 @@ import signal
1717import socket
1818import subprocess
1919import sys
20+ import tempfile
2021import time
2122import urllib .error
2223import urllib .parse
@@ -26,7 +27,9 @@ from typing import Any
2627
2728HOME = pathlib .Path .home ()
2829BASE_DIR = pathlib .Path (os .environ .get ("YTA_CONFIG_DIR" , HOME / ".config/youtube-autoencoder" )).expanduser ()
29- CLIENT_FILE = pathlib .Path (os .environ .get ("YTA_YOUTUBE_CLIENT_FILE" , BASE_DIR / "google-oauth-client.json" )).expanduser ()
30+ CLIENT_FILE = pathlib .Path (
31+ os .environ .get ("YTA_YOUTUBE_CLIENT_FILE" , BASE_DIR / "google-oauth-client.json" )
32+ ).expanduser ()
3033TOKEN_FILE = pathlib .Path (os .environ .get ("YTA_YOUTUBE_TOKEN_FILE" , BASE_DIR / "youtube-token.json" )).expanduser ()
3134STATE_FILE = pathlib .Path (os .environ .get ("YTA_YOUTUBE_STATE_FILE" , BASE_DIR / "youtube-live-state.json" )).expanduser ()
3235LOCK_FILE = pathlib .Path (os .environ .get ("YTA_YOUTUBE_LOCK_FILE" , BASE_DIR / "youtube-live-state.lock" )).expanduser ()
@@ -40,9 +43,11 @@ API_BASE = "https://www.googleapis.com/youtube/v3"
4043child : subprocess .Popen [Any ] | None = None
4144stopping = False
4245RATE_LIMIT_REASONS = {
46+ "concurrentBroadcastsExceedLimit" ,
4347 "dailyLimitExceeded" ,
4448 "quotaExceeded" ,
4549 "rateLimitExceeded" ,
50+ "sharedIngestionBroadcastsExceedLimit" ,
4651 "userRateLimitExceeded" ,
4752 "userRequestsExceedRateLimit" ,
4853}
@@ -103,16 +108,28 @@ def fsync_directory(path: pathlib.Path) -> None:
103108
104109def write_json_durable (path : pathlib .Path , data : dict [str , Any ], mode : int = 0o600 ) -> None :
105110 path .parent .mkdir (parents = True , exist_ok = True )
106- tmp = path .with_suffix (path .suffix + ".tmp" )
107- with tmp .open ("w" , encoding = "utf-8" ) as handle :
108- json .dump (data , handle , indent = 2 , sort_keys = True )
109- handle .write ("\n " )
110- handle .flush ()
111- os .fsync (handle .fileno ())
112- os .chmod (tmp , mode )
113- tmp .replace (path )
114- os .chmod (path , mode )
115- fsync_directory (path .parent )
111+ raw_fd , tmp_name = tempfile .mkstemp (
112+ prefix = f".{ path .name } ." ,
113+ suffix = ".tmp" ,
114+ dir = path .parent ,
115+ )
116+ tmp = pathlib .Path (tmp_name )
117+ try :
118+ os .fchmod (raw_fd , mode )
119+ file_handle = os .fdopen (raw_fd , "w" , encoding = "utf-8" )
120+ raw_fd = - 1
121+ with file_handle as handle :
122+ json .dump (data , handle , indent = 2 , sort_keys = True )
123+ handle .write ("\n " )
124+ handle .flush ()
125+ os .fsync (handle .fileno ())
126+ tmp .replace (path )
127+ os .chmod (path , mode )
128+ fsync_directory (path .parent )
129+ finally :
130+ if raw_fd >= 0 :
131+ os .close (raw_fd )
132+ tmp .unlink (missing_ok = True )
116133
117134
118135def write_secret_json (path : pathlib .Path , data : dict [str , Any ]) -> None :
@@ -223,11 +240,7 @@ def http_json(
223240 details = parsed .get ("error" ) if isinstance (parsed , dict ) else None
224241 details = details if isinstance (details , dict ) else {}
225242 errors = details .get ("errors" ) or []
226- reasons = tuple (
227- str (item .get ("reason" ))
228- for item in errors
229- if isinstance (item , dict ) and item .get ("reason" )
230- )
243+ reasons = tuple (str (item .get ("reason" )) for item in errors if isinstance (item , dict ) and item .get ("reason" ))
231244 message = str (details .get ("message" ) or exc .reason or "YouTube API request failed" )
232245 retry_after = None
233246 retry_after_value = exc .headers .get ("Retry-After" ) if exc .headers else None
@@ -350,7 +363,7 @@ def list_streams() -> list[dict[str, Any]]:
350363def find_obs_stream () -> dict [str , Any ]:
351364 stream_name = obs_stream_name ()
352365 for stream in list_streams ():
353- ingestion = (( stream .get ("cdn" ) or {}).get ("ingestionInfo" ) or {})
366+ ingestion = (stream .get ("cdn" ) or {}).get ("ingestionInfo" ) or {}
354367 if ingestion .get ("streamName" ) == stream_name :
355368 return stream
356369 raise LookupError ("no YouTube liveStream matched the configured OBS stream key" )
@@ -373,7 +386,7 @@ def create_stream() -> dict[str, Any]:
373386
374387
375388def save_stream_to_obs (stream : dict [str , Any ]) -> None :
376- ingestion = (( stream .get ("cdn" ) or {}).get ("ingestionInfo" ) or {})
389+ ingestion = (stream .get ("cdn" ) or {}).get ("ingestionInfo" ) or {}
377390 stream_name = ingestion .get ("streamName" )
378391 server = ingestion .get ("rtmpsIngestionAddress" ) or ingestion .get ("ingestionAddress" )
379392 if not stream_name or not server :
@@ -430,13 +443,9 @@ def has_marker(broadcast: dict[str, Any], marker: str) -> bool:
430443
431444def broadcast_generation (broadcast : dict [str , Any ]) -> str :
432445 description = str ((broadcast .get ("snippet" ) or {}).get ("description" ) or "" )
433- marker_pattern = re .compile (
434- rf"^\[{ re .escape (GENERATION_MARKER_PREFIX )} :([A-Za-z0-9._-]+)\]$"
435- )
446+ marker_pattern = re .compile (rf"^\[{ re .escape (GENERATION_MARKER_PREFIX )} :([A-Za-z0-9._-]+)\]$" )
436447 generations = [
437- match .group (1 )
438- for line in description .splitlines ()
439- if (match := marker_pattern .fullmatch (line )) is not None
448+ match .group (1 ) for line in description .splitlines () if (match := marker_pattern .fullmatch (line )) is not None
440449 ]
441450 if len (generations ) != 1 :
442451 raise ReconciliationError ("managed broadcast must contain exactly one generation marker" )
@@ -539,9 +548,7 @@ def lifecycle_state(
539548 return state
540549
541550
542- def validate_candidate (
543- broadcast : dict [str , Any ], * , instance : str , generation : str | None , stream_id : str
544- ) -> None :
551+ def validate_candidate (broadcast : dict [str , Any ], * , instance : str , generation : str | None , stream_id : str ) -> None :
545552 lifecycle = broadcast_lifecycle (broadcast )
546553 if lifecycle not in KNOWN_LIFECYCLE_STATES :
547554 raise ReconciliationError (f"unknown YouTube broadcast lifecycle: { lifecycle or '<empty>' } " )
@@ -578,9 +585,7 @@ def choose_candidate(
578585 return candidate
579586
580587
581- def reconcile_broadcast (
582- * , stream_id : str , title : str , staging_privacy : str , allow_create : bool
583- ) -> dict [str , Any ]:
588+ def reconcile_broadcast (* , stream_id : str , title : str , staging_privacy : str , allow_create : bool ) -> dict [str , Any ]:
584589 with mutation_lock (timeout = lock_timeout ()):
585590 instance = instance_id ()
586591 state = read_state ()
@@ -650,8 +655,7 @@ def reconcile_broadcast(
650655 stream_status_value = str ((stream .get ("status" ) or {}).get ("streamStatus" ) or "" )
651656 if stream_status_value != "active" :
652657 raise ReconciliationError (
653- "YouTube ingest stopped before broadcast creation; "
654- f"got { stream_status_value or 'unknown' } "
658+ f"YouTube ingest stopped before broadcast creation; got { stream_status_value or 'unknown' } "
655659 )
656660 broadcast = create_broadcast (
657661 title ,
@@ -925,9 +929,7 @@ def stream_status_command(args: argparse.Namespace) -> int:
925929
926930def broadcast_status_command (args : argparse .Namespace ) -> int :
927931 state = read_state ()
928- broadcast_id = args .broadcast_id or str (
929- state .get ("broadcast_id" ) or state .get ("last_broadcast_id" ) or ""
930- )
932+ broadcast_id = args .broadcast_id or str (state .get ("broadcast_id" ) or state .get ("last_broadcast_id" ) or "" )
931933 if not broadcast_id :
932934 raise ValueError ("no broadcast id provided and no previous state file found" )
933935 print (json .dumps (broadcast_status (broadcast_id ), indent = 2 , sort_keys = True ))
@@ -937,9 +939,7 @@ def broadcast_status_command(args: argparse.Namespace) -> int:
937939def set_privacy_command (args : argparse .Namespace ) -> int :
938940 with mutation_lock (timeout = lock_timeout ()):
939941 state = read_state ()
940- broadcast_id = args .broadcast_id or str (
941- state .get ("broadcast_id" ) or state .get ("last_broadcast_id" ) or ""
942- )
942+ broadcast_id = args .broadcast_id or str (state .get ("broadcast_id" ) or state .get ("last_broadcast_id" ) or "" )
943943 if not broadcast_id :
944944 raise ValueError ("no broadcast id provided and no previous state file found" )
945945 result = set_broadcast_privacy (broadcast_id , args .privacy )
@@ -1050,11 +1050,15 @@ def run_visible_test(args: argparse.Namespace) -> int:
10501050def complete (args : argparse .Namespace ) -> int :
10511051 with mutation_lock (timeout = lock_timeout ()):
10521052 state = read_state ()
1053- broadcast_id = args .broadcast_id or str (
1054- state .get ("broadcast_id" ) or state .get ("last_broadcast_id" ) or ""
1055- )
1053+ broadcast_id = args .broadcast_id or str (state .get ("broadcast_id" ) or state .get ("last_broadcast_id" ) or "" )
10561054 if not broadcast_id :
10571055 raise ValueError ("no broadcast id provided and no previous state file found" )
1056+ current = broadcast_by_id (broadcast_id )
1057+ lifecycle = broadcast_lifecycle (current or {})
1058+ if current is None or lifecycle != "live" :
1059+ raise ReconciliationError (
1060+ f"refusing completion because broadcast is not confirmed live: { lifecycle or 'missing' } "
1061+ )
10581062 result = transition (broadcast_id , "complete" )
10591063 print (json .dumps ({"id" : result .get ("id" ), "status" : result .get ("status" )}, indent = 2 , sort_keys = True ))
10601064 return 0
@@ -1063,9 +1067,7 @@ def complete(args: argparse.Namespace) -> int:
10631067def transition_command (args : argparse .Namespace ) -> int :
10641068 with mutation_lock (timeout = lock_timeout ()):
10651069 state = read_state ()
1066- broadcast_id = args .broadcast_id or str (
1067- state .get ("broadcast_id" ) or state .get ("last_broadcast_id" ) or ""
1068- )
1070+ broadcast_id = args .broadcast_id or str (state .get ("broadcast_id" ) or state .get ("last_broadcast_id" ) or "" )
10691071 if not broadcast_id :
10701072 raise ValueError ("no broadcast id provided and no previous state file found" )
10711073 result = transition (broadcast_id , args .status )
@@ -1097,10 +1099,16 @@ def main() -> int:
10971099 broadcast_status_p .set_defaults (func = broadcast_status_command )
10981100
10991101 prepare_p = sub .add_parser ("prepare-broadcast" , help = "Create and bind a broadcast for the reusable stream" )
1100- prepare_p .add_argument ("--privacy" , choices = ["public" , "unlisted" , "private" ], default = os .environ .get ("YTA_YOUTUBE_PRIVACY" , "unlisted" ))
1102+ prepare_p .add_argument (
1103+ "--privacy" ,
1104+ choices = ["public" , "unlisted" , "private" ],
1105+ default = os .environ .get ("YTA_YOUTUBE_PRIVACY" , "unlisted" ),
1106+ )
11011107 prepare_p .add_argument ("--title" )
11021108 prepare_p .add_argument ("--title-prefix" , default = os .environ .get ("YTA_YOUTUBE_TITLE_PREFIX" , "AutoEncoder Live" ))
1103- prepare_p .add_argument ("--create-stream" , action = "store_true" , help = "Create a reusable stream if OBS key is not found" )
1109+ prepare_p .add_argument (
1110+ "--create-stream" , action = "store_true" , help = "Create a reusable stream if OBS key is not found"
1111+ )
11041112 prepare_p .set_defaults (func = prepare_broadcast )
11051113
11061114 reconcile_p = sub .add_parser (
@@ -1112,9 +1120,7 @@ def main() -> int:
11121120 default = os .environ .get ("YTA_YOUTUBE_PRIVACY" , "unlisted" ),
11131121 )
11141122 reconcile_p .add_argument ("--title" )
1115- reconcile_p .add_argument (
1116- "--title-prefix" , default = os .environ .get ("YTA_YOUTUBE_TITLE_PREFIX" , "AutoEncoder Live" )
1117- )
1123+ reconcile_p .add_argument ("--title-prefix" , default = os .environ .get ("YTA_YOUTUBE_TITLE_PREFIX" , "AutoEncoder Live" ))
11181124 reconcile_p .add_argument (
11191125 "--create-stream" , action = "store_true" , help = "Create a reusable stream if OBS key is not found"
11201126 )
@@ -1134,7 +1140,11 @@ def main() -> int:
11341140 test_p .add_argument ("--duration" , type = int , default = 900 )
11351141 test_p .add_argument ("--wait-stream-active" , type = int , default = 120 )
11361142 test_p .add_argument ("--testing-delay" , type = int , default = 8 )
1137- test_p .add_argument ("--privacy" , choices = ["public" , "unlisted" , "private" ], default = os .environ .get ("YTA_YOUTUBE_PRIVACY" , "unlisted" ))
1143+ test_p .add_argument (
1144+ "--privacy" ,
1145+ choices = ["public" , "unlisted" , "private" ],
1146+ default = os .environ .get ("YTA_YOUTUBE_PRIVACY" , "unlisted" ),
1147+ )
11381148 test_p .add_argument ("--title" )
11391149 test_p .add_argument ("--create-stream" , action = "store_true" , help = "Create a reusable stream if OBS key is not found" )
11401150 test_p .add_argument (
@@ -1150,7 +1160,7 @@ def main() -> int:
11501160 complete_p .set_defaults (func = complete )
11511161
11521162 transition_p = sub .add_parser ("transition" , help = "Transition the last or specified broadcast" )
1153- transition_p .add_argument ("status" , choices = ["testing" , "live" , "complete" ])
1163+ transition_p .add_argument ("status" , choices = ["testing" , "live" ])
11541164 transition_p .add_argument ("broadcast_id" , nargs = "?" )
11551165 transition_p .set_defaults (func = transition_command )
11561166
0 commit comments