Spaces:
Running
Running
File size: 8,291 Bytes
9c10fcb 7ea6aba 9c10fcb e4951d8 0f79a08 e4951d8 0f79a08 b1e4f60 e4951d8 b1e4f60 9c10fcb 50594d5 ce0ef24 50594d5 ce0ef24 50594d5 ce0ef24 50594d5 ce0ef24 50594d5 9c10fcb ce0ef24 9c10fcb ce0ef24 9c10fcb ce0ef24 9c10fcb ce0ef24 50594d5 ce0ef24 50594d5 ce0ef24 9c10fcb b1e4f60 9c10fcb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 | """Media ingest helpers. Video path is RTMPS; OAuth is optional metadata only.
Never log the stream key or the full RTMP URL.
"""
import os, threading, time
from datetime import datetime
from zoneinfo import ZoneInfo
APP_VERSION = 'relay-v2.45-probed-canvas'
def env_rtmp_destination():
"""Build rtmps://…/key from secrets. Empty string if not configured."""
url = (os.getenv('YOUTUBE_RTMP_URL') or os.getenv('YOUTUBE_RTMPS_URL') or '').strip()
key = (os.getenv('YOUTUBE_STREAM_KEY') or os.getenv('YOUTUBE_RTMP_KEY') or '').strip()
if url.startswith(('rtmp://', 'rtmps://')) and key:
if url.rstrip('/').endswith('/' + key):
return url
return url.rstrip('/') + '/' + key
if url.startswith(('rtmp://', 'rtmps://')):
return url
if key:
return 'rtmps://a.rtmps.youtube.com:443/live2/' + key
return ''
def rtmp_configured():
return bool(env_rtmp_destination())
def max_concurrent_relays():
"""Cap on simultaneous FFmpeg publishers. Not a quality guarantee.
Default 2: cpu-basic has carried 1 comfortably and 2 at high quota; 3+ is
not a pass. Operators may raise MAX_CONCURRENT_RELAYS; health reports the
value actually in force. Ceiling 8 is only a fork-bomb guard.
"""
raw = os.getenv('MAX_CONCURRENT_RELAYS')
if raw is None or str(raw).strip() == '':
raw = os.getenv('YOUTUBE_MAX_EVENTS') or '0'
try:
n = int(str(raw).strip())
except (TypeError, ValueError):
n = 0
# 0 = every LIVE account may copy (one FFmpeg per user). No hidden ceiling.
return n if n > 0 else 0
def light_max():
"""Operator opt-in ceiling for legacy x264 fallback; zero disables video re-encoding.
The default is deliberately zero: AVC and Enhanced-RTMP HEVC are copied, including decoder
configuration replacement. Historical CBR/x264 modes remain available only by explicitly
raising this limit.
"""
try:
n = int(str(os.getenv('YOUTUBE_LIGHT_MAX', '0')).strip() or '0')
except (TypeError, ValueError):
n = 0
return max(0, min(n, 8))
def format_session_title(broadcaster, when=None):
"""Baghdad clock of this TikTok LIVE session — not process start time."""
when = when or datetime.now(ZoneInfo('Asia/Baghdad'))
values = {
'broadcaster': broadcaster or '',
'name': broadcaster or '',
'nickname': broadcaster or '',
'date': when.strftime('%Y-%m-%d'),
'time': when.strftime('%H:%M'),
'title': broadcaster or '',
'username': '',
'hashtags': '',
'room_id': '',
'live_title': '',
}
tpl = (os.getenv('YOUTUBE_TITLE_TEMPLATE') or '{broadcaster} | {date} {time}').strip()
try:
title = tpl.format(**values).strip()[:100]
except (KeyError, ValueError, IndexError, AttributeError):
title = '%s | %s %s' % (values['broadcaster'], values['date'], values['time'])
return title, values['date'], values['time']
class RelayHub:
"""At most one FFmpeg relay per broadcaster. Owners: 'oauth' or 'direct'."""
def __init__(self):
self.lock = threading.RLock()
self.slots = {}
self.restarts = {}
# Atomic per-broadcaster reservations prevent simultaneous format fallbacks from
# exceeding YOUTUBE_LIGHT_MAX while their existing FFmpeg children are still exiting.
self.light_reservations = set()
@staticmethod
def _ffmpeg_alive(cur):
snd = (cur or {}).get('sender')
try:
return bool(snd and snd.alive())
except Exception:
return False
@staticmethod
def _supervisor_alive(cur):
if not cur:
return False
th = cur.get('thread')
if th and th.is_alive():
return True
return RelayHub._ffmpeg_alive(cur)
def occupy(self, user, owner, sender=None, thread=None, room_id='', title='', dest=''):
with self.lock:
cur = self.slots.get(user)
if self._supervisor_alive(cur):
return False
if cur:
self.restarts[user] = int(self.restarts.get(user) or 0) + 1
self.slots[user] = {
'owner': owner, 'sender': sender, 'thread': thread,
'room_id': room_id, 'title': title, 'started': time.time(),
'stop': None,
'dest': dest or ('env' if owner == 'direct' else 'oauth'),
}
return True
def update(self, user, **kw):
with self.lock:
if user in self.slots:
self.slots[user].update(kw)
def release(self, user, owner=None):
with self.lock:
cur = self.slots.get(user)
if not cur:
self.light_reservations.discard(user)
return
if owner and cur.get('owner') != owner:
return
self.slots.pop(user, None)
self.light_reservations.discard(user)
def busy(self, user):
with self.lock:
return self._supervisor_alive(self.slots.get(user))
def owner(self, user):
with self.lock:
return (self.slots.get(user) or {}).get('owner')
def snapshot(self, user=None):
with self.lock:
if user is not None:
return dict(self.slots.get(user) or {})
return {u: dict(v) for u, v in self.slots.items()}
def active_count(self):
"""FFmpeg processes actually pushing — not idle supervisor threads."""
with self.lock:
return sum(1 for v in self.slots.values() if self._ffmpeg_alive(v))
def _light_count_locked(self, except_user=None):
"""Count active encoders plus reserved slots, without counting either twice."""
active=set();n=0
for u,v in self.slots.items():
if except_user and u==except_user:continue
if u in self.light_reservations:
active.add(u);n+=1
continue
if not self._ffmpeg_alive(v):continue
snd=v.get('sender');mode=''
try:
mode=str(getattr(snd,'sticky_mode',None) or '')
if not mode and snd:
snap=snd.snapshot() or {};mode=str(snap.get('send_mode') or '')
except Exception:pass
if mode in ('matchgop','fixed720','fixed1080'):
active.add(u);n+=1
n+=sum(1 for u in self.light_reservations if u!=except_user and u not in active)
return n
def light_count(self, except_user=None):
"""Publishers and atomic reservations using matchgop/fixed720/fixed1080."""
with self.lock:return self._light_count_locked(except_user)
def light_available(self, except_user=None):
return self.light_count(except_user) < light_max()
def reserve_light(self,user):
"""Atomically claim one per-user encoder slot before changing a live publisher."""
user=str(user or '')
if not user:return False
with self.lock:
if user in self.light_reservations:return True
limit=light_max()
if limit<=0 or self._light_count_locked(except_user=user)>=limit:return False
self.light_reservations.add(user)
return True
def release_light(self,user):
with self.lock:self.light_reservations.discard(str(user or ''))
def restart_count(self, user):
with self.lock:
return int(self.restarts.get(user) or 0)
def at_capacity(self, except_user=None):
with self.lock:
n = sum(1 for u, v in self.slots.items()
if u != except_user and self._ffmpeg_alive(v))
cap = max_concurrent_relays()
if cap <= 0:
return False
return n >= cap
def env_key_busy(self, except_user=None):
"""A single env stream key can only accept one publisher (any owner)."""
with self.lock:
for u, v in self.slots.items():
if except_user and u == except_user:
continue
if v.get('dest') == 'env' and self._supervisor_alive(v):
return True
return False
hub = RelayHub()
|