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()