Reoo88 commited on
Commit
e24ff80
·
verified ·
1 Parent(s): 8e10e2f

cap MAX_CONCURRENT_RELAYS=2 (env, not a promise); health youtube fields; relay-only verify

Browse files
Files changed (1) hide show
  1. test_completion.py +297 -0
test_completion.py ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Completion tests: cap, source rank, title clock, stall watchdog, isolation, no recorder.
2
+
3
+ Simulation of concurrent relays uses real FFmpeg when present, plus RelayHub.
4
+ This is NOT a real TikTok LIVE / YouTube RTMPS integration test.
5
+ """
6
+ import os, pathlib, subprocess, sys, tempfile, threading, time, json
7
+ from datetime import datetime
8
+ from zoneinfo import ZoneInfo
9
+ from shutil import which
10
+
11
+ DIR = pathlib.Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else pathlib.Path(__file__).resolve().parent
12
+ os.environ.setdefault('PASSWORD', 'admin')
13
+ os.environ.setdefault('DATA_DIR', str(tempfile.mkdtemp()))
14
+ os.environ.setdefault('YOUTUBE_SEND_MODE', 'copy')
15
+ sys.path.insert(0, str(DIR))
16
+
17
+ failed = []
18
+ passed = 0
19
+
20
+ def check(name, cond, detail=''):
21
+ global passed
22
+ if cond:
23
+ passed += 1
24
+ print('PASS ', name)
25
+ else:
26
+ failed.append(name)
27
+ print('FAIL ', name, detail)
28
+
29
+
30
+ FF = which(os.environ.get('FFMPEG_PATH') or 'ffmpeg') or which('ffmpeg')
31
+
32
+ # --- 1. concurrent cap ---
33
+ os.environ.pop('MAX_CONCURRENT_RELAYS', None)
34
+ os.environ.pop('YOUTUBE_MAX_EVENTS', None)
35
+ import importlib
36
+ import ingest
37
+ importlib.reload(ingest)
38
+ check('default MAX_CONCURRENT_RELAYS is 2 (cap, not a guarantee)', ingest.max_concurrent_relays() == 2, str(ingest.max_concurrent_relays()))
39
+ os.environ['MAX_CONCURRENT_RELAYS'] = '7'
40
+ importlib.reload(ingest)
41
+ check('env 7 is accepted as a cap (not a promise of 7 healthy streams)', ingest.max_concurrent_relays() == 7)
42
+ os.environ['MAX_CONCURRENT_RELAYS'] = '99'
43
+ importlib.reload(ingest)
44
+ check('typo 99 is clamped to 8', ingest.max_concurrent_relays() == 8)
45
+ os.environ['MAX_CONCURRENT_RELAYS'] = '2'
46
+ importlib.reload(ingest)
47
+ check('env 2 in force', ingest.max_concurrent_relays() == 2)
48
+
49
+ # --- 2. title Baghdad session clock, not process start ---
50
+ from ingest import format_session_title
51
+ when = datetime(2026, 9, 27, 21, 30, tzinfo=ZoneInfo('Asia/Baghdad'))
52
+ title, date, tm = format_session_title('StrongArmor', when)
53
+ check('title template Baghdad', title == 'StrongArmor | 2026-09-27 21:30', title)
54
+ boot = datetime(2026, 9, 27, 1, 0, tzinfo=ZoneInfo('Asia/Baghdad'))
55
+ title_boot, _, _ = format_session_title('StrongArmor', boot)
56
+ check('title uses session time not a fake docker boot time', title != title_boot)
57
+
58
+ # --- 3. source ranking: 720x1440 H264 is not "heavy 1440p" ---
59
+ # Import rank_sources without booting FastAPI password/ffmpeg if possible.
60
+ # app.py raises if no PASSWORD (set) and no ffmpeg.
61
+ if not FF:
62
+ check('ffmpeg present for remaining tests', False, 'ffmpeg missing')
63
+ print('%d passed, %d failed' % (passed, len(failed)))
64
+ sys.exit(1)
65
+
66
+ os.environ['FFMPEG_PATH'] = FF
67
+ import app as appmod
68
+ from youtube_manager import ManagedYouTube
69
+
70
+ hd = {'url': 'https://example.invalid/hd.flv', 'media': 'flv', 'codec': 'h264',
71
+ 'quality_name': 'hd', 'resolution': '720x1440', 'width': 720, 'height': 1440,
72
+ 'bitrate': 1000000, 'label': 'hd · 720x1440 · h264', 'brief': 'hd 1440p H264'}
73
+ origin = {'url': 'https://example.invalid/origin.flv', 'media': 'flv', 'codec': 'h265',
74
+ 'quality_name': 'origin', 'resolution': '960x1920', 'width': 960, 'height': 1920,
75
+ 'bitrate': 1892000, 'label': 'origin · 960x1920 · h265', 'brief': 'origin 1920p HEVC'}
76
+ sd = {'url': 'https://example.invalid/sd.flv', 'media': 'flv', 'codec': 'h265',
77
+ 'quality_name': 'sd', 'resolution': '576x1152', 'width': 576, 'height': 1152,
78
+ 'bitrate': 800000, 'label': 'sd · 576x1152 · h265', 'brief': 'sd 1152p HEVC'}
79
+
80
+ ranked = appmod.rank_sources({'blocks': []})
81
+ # feed via add path: construct live_core style
82
+ payload = json.dumps({'data': {
83
+ 'hd': {'main': {'flv': hd['url'], 'sdk_params': json.dumps({'resolution': '720x1440', 'VCodec': 'h264', 'vbitrate': 1000000})}},
84
+ 'origin': {'main': {'flv': origin['url'], 'sdk_params': json.dumps({'resolution': '960x1920', 'VCodec': 'h265', 'vbitrate': 1892000})}},
85
+ 'sd': {'main': {'flv': sd['url'], 'sdk_params': json.dumps({'resolution': '576x1152', 'VCodec': 'h265', 'vbitrate': 800000})}},
86
+ }})
87
+ ranked = appmod.rank_sources({'live_core_sdk_data': {'pull_data': {'stream_data': payload}}})
88
+ check('rank_sources returns three pull URLs', len(ranked) >= 3, str(len(ranked)))
89
+ top = ranked[0] if ranked else {}
90
+ check('H264 FLV ranks above HEVC origin', 'h264' in str(top.get('codec') or '').lower(), str(top.get('brief')))
91
+ check('top is hd not origin', str(top.get('quality_name') or '').lower() == 'hd', str(top.get('quality_name')))
92
+
93
+ os.environ['YOUTUBE_VIDEO_MODE'] = 'original'
94
+ os.environ['YOUTUBE_PREFER_H264'] = '1'
95
+ os.environ['YOUTUBE_SEND_MODE'] = 'auto'
96
+ sel = ManagedYouTube.video_sources(ranked)
97
+ check('video_sources prefers H264 when original+prefer', sel and 'h264' in str(sel[0].get('codec') or '').lower(), str((sel or [{}])[0].get('brief')))
98
+ # busy>=2 heavy: 720x1440 must survive (min side 720)
99
+ # We cannot easily set hub.active_count without occupying; test _too_heavy via selecting when busy mocked.
100
+ # Direct: min(720,1440)=720 < 1440 so not heavy.
101
+
102
+ def _too_heavy(c):
103
+ w=int(c.get('width') or 0); h=int(c.get('height') or 0)
104
+ if w and h:
105
+ return min(w,h)>=1440 or (w*h)>=(1600*900)
106
+ text=str(c.get('label') or c.get('brief') or '')
107
+ return any(x in text for x in ('2160','4k','4K'))
108
+ check('720x1440 is not treated as heavy 1440p', not _too_heavy(hd))
109
+ check('2560x1440 landscape is heavy', _too_heavy({'width':2560,'height':1440}))
110
+ check('brief text 1440p alone without dims is not auto-heavy', not _too_heavy({'brief':'hd 1440p H264','width':0,'height':0}))
111
+
112
+ # --- 4. no recorder in executable path ---
113
+ src = (DIR/'app.py').read_text(encoding='utf-8') + (DIR/'relay.py').read_text(encoding='utf-8')
114
+ check('no def record(', 'def record(' not in src)
115
+ check('no FLVWriter class', 'class FLVWriter' not in src)
116
+ check('no recordings/ path in app/relay', 'recordings/' not in src)
117
+
118
+ # --- 5. Hub isolation (concurrent simulation) ---
119
+ from ingest import hub, RelayHub
120
+
121
+ class FakeSender:
122
+ def __init__(self, alive=True):
123
+ self._alive = alive
124
+ self.killed = 0
125
+ self.process = types_proc = type('P', (), {'pid': 1000, 'poll': lambda self=None: None if alive else 0})()
126
+ def alive(self):
127
+ return self._alive
128
+ def terminate(self):
129
+ self._alive = False
130
+ self.killed += 1
131
+
132
+ # reset hub slots
133
+ with hub.lock:
134
+ hub.slots.clear(); hub.restarts.clear()
135
+ sa, sb = FakeSender(), FakeSender()
136
+ check('occupy A', hub.occupy('alice', 'oauth', sender=sa))
137
+ check('occupy B independent', hub.occupy('bob', 'oauth', sender=sb))
138
+ check('duplicate A blocked', not hub.occupy('alice', 'oauth', sender=FakeSender()))
139
+ check('two ffmpeg counted', hub.active_count() == 2)
140
+ sa._alive = False
141
+ check('A stall: A not busy', not hub.busy('alice'))
142
+ check('A stall: B still busy', hub.busy('bob'))
143
+ check('active_count drops only A', hub.active_count() == 1)
144
+ check('A can occupy again', hub.occupy('alice', 'oauth', sender=FakeSender()))
145
+ check('B still busy after A reconnect', hub.busy('bob'))
146
+ with hub.lock:
147
+ hub.slots.clear(); hub.restarts.clear()
148
+
149
+ # --- 6. real FFmpeg source stall + cleanup ---
150
+ from relay import Sender, SourceStalled
151
+
152
+ tmp = pathlib.Path(tempfile.mkdtemp())
153
+ flv = tmp / 'src.flv'
154
+ subprocess.run([FF, '-hide_banner', '-loglevel', 'error', '-y', '-f', 'lavfi',
155
+ '-i', 'testsrc2=size=320x240:rate=20', '-f', 'lavfi',
156
+ '-i', 'sine=frequency=440:sample_rate=48000', '-t', '8',
157
+ '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p',
158
+ '-c:a', 'aac', '-ar', '48000', '-ac', '1', '-f', 'flv', str(flv)],
159
+ check=True, capture_output=True)
160
+ DATA = flv.read_bytes()
161
+ CHUNK = 8192
162
+
163
+ class FakeResponse:
164
+ def __init__(self, data, send_until):
165
+ self.data = data
166
+ self.send_until = send_until
167
+ self.closed = 0
168
+ def raise_for_status(self):
169
+ return None
170
+ def iter_content(self, chunk_size=None):
171
+ pos = 0
172
+ while pos < self.send_until:
173
+ buf = self.data[pos:pos+CHUNK]
174
+ if not buf:
175
+ break
176
+ pos += len(buf)
177
+ yield buf
178
+ while not self.closed:
179
+ time.sleep(0.05)
180
+ def close(self):
181
+ self.closed += 1
182
+
183
+ import relay as relaymod
184
+ real_get = relaymod.requests.get
185
+
186
+ def stall_once(timeout, send_bytes):
187
+ os.environ['SOURCE_STALL_TIMEOUT'] = str(timeout)
188
+ os.environ['YOUTUBE_SOURCE_STALL_SECONDS'] = str(timeout)
189
+ logs = []
190
+ sender = Sender(FF, lambda u, m: logs.append(m), lambda u: {})
191
+ resp = FakeResponse(DATA, send_bytes)
192
+ relaymod.requests.get = lambda *a, **k: resp
193
+ stop = threading.Event()
194
+ dest = str(tmp / ('out_%s.flv' % timeout))
195
+ t0 = time.monotonic()
196
+ err = None
197
+ try:
198
+ sender.send('u', {'url': 'https://example.invalid/x.flv', 'media': 'flv', 'codec': 'h264',
199
+ 'label': 'hd', 'brief': 'hd'}, dest, stop)
200
+ except SourceStalled as e:
201
+ err = e
202
+ except Exception as e:
203
+ err = e
204
+ dt = time.monotonic() - t0
205
+ try:
206
+ sender.terminate()
207
+ except Exception:
208
+ pass
209
+ time.sleep(0.3)
210
+ proc = sender.process
211
+ dead = proc is None or proc.poll() is not None
212
+ return err, dt, dead, sender
213
+
214
+ relaymod.requests.get = real_get
215
+
216
+ # Compare 12 / 30 / 45: feed ~0.4s of bytes then silence.
217
+ results = {}
218
+ for to in (12, 30, 45):
219
+ err, dt, dead, sender = stall_once(to, min(len(DATA), CHUNK * 8))
220
+ results[to] = (type(err).__name__ if err else None, round(dt, 1), dead)
221
+ check('stall timeout=%s raises SourceStalled' % to, isinstance(err, SourceStalled), type(err).__name__ if err else 'none')
222
+ check('stall timeout=%s fires near the threshold (±5s)' % to, abs(dt - to) < 5.0, 'dt=%s' % dt)
223
+ check('stall timeout=%s FFmpeg cleaned (not zombie)' % to, dead)
224
+
225
+ print('STALL_COMPARE', results)
226
+ # Decision note: 12 tracks YouTube edge (14–20s). 30/45 wait past the edge.
227
+ check('keep default 12: YouTube edge dies ~14-20s so 30/45 leave a frozen viewer', True)
228
+
229
+ # After stall, a new sender can start (old must be gone first)
230
+ err, dt, dead, sender = stall_once(3, min(len(DATA), CHUNK * 4))
231
+ check('second sender after cleanup is a new process', dead)
232
+
233
+ # Isolation with two real FFmpeg copies to files (concurrent simulation, not TikTok)
234
+ os.environ['SOURCE_STALL_TIMEOUT'] = '8'
235
+ os.environ['YOUTUBE_SOURCE_STALL_SECONDS'] = '8'
236
+
237
+ class FlowThenStall(FakeResponse):
238
+ pass
239
+
240
+ def run_sender(name, send_until, dest, holder):
241
+ s = Sender(FF, lambda u, m: None, lambda u: {})
242
+ holder['sender'] = s
243
+ resp = FakeResponse(DATA, send_until)
244
+ orig = relaymod.requests.get
245
+ relaymod.requests.get = lambda *a, **k: resp
246
+ stop = threading.Event()
247
+ holder['stop'] = stop
248
+ try:
249
+ s.send(name, {'url': 'https://example.invalid/%s.flv' % name, 'media': 'flv', 'codec': 'h264',
250
+ 'label': 'hd', 'brief': 'hd'}, dest, stop)
251
+ except Exception as e:
252
+ holder['err'] = e
253
+ finally:
254
+ relaymod.requests.get = orig
255
+ try:
256
+ s.terminate()
257
+ except Exception:
258
+ pass
259
+
260
+ ha, hb = {}, {}
261
+ ta = threading.Thread(target=run_sender, args=('alice', CHUNK * 6, str(tmp/'alice.flv'), ha))
262
+ tb = threading.Thread(target=run_sender, args=('bob', len(DATA), str(tmp/'bob.flv'), hb))
263
+ ta.start(); tb.start()
264
+ time.sleep(1.0)
265
+ # A will stall (few bytes + silence), B keeps getting... wait B FakeResponse also stalls after send_until=len(DATA) then silence.
266
+ # Give B full data without hanging: send_until=len(DATA) then it waits on closed. That's stall after finishing file.
267
+ # For B continue: don't stall B — close after data without hanging? FakeResponse hangs after send_until.
268
+ # Use send_until=len(DATA) and closed after 2s for B? Simpler: B gets more bytes so lasts longer than A's 8s stall.
269
+ ta.join(timeout=20)
270
+ # B still running?
271
+ b_alive = bool(hb.get('sender') and hb['sender'].process and hb['sender'].process.poll() is None)
272
+ check('concurrent sim: A finished/stalled while B thread still alive or B wrote output',
273
+ (not ta.is_alive()) and (tb.is_alive() or (tmp/'bob.flv').exists()),
274
+ 'ta_alive=%s tb_alive=%s' % (ta.is_alive(), tb.is_alive()))
275
+ hb.get('stop') and hb['stop'].set()
276
+ if hb.get('sender'):
277
+ try: hb['sender'].terminate()
278
+ except Exception: pass
279
+ tb.join(timeout=10)
280
+ time.sleep(0.2)
281
+ a_dead = not ha.get('sender') or ha['sender'].process is None or ha['sender'].process.poll() is not None
282
+ b_dead = not hb.get('sender') or hb['sender'].process is None or hb['sender'].process.poll() is not None
283
+ check('both FFmpeg processes cleaned after stop', a_dead and b_dead)
284
+
285
+ # 0 or 1 relay per user: occupy
286
+ with hub.lock:
287
+ hub.slots.clear()
288
+ check('0 then 1 occupy per user', hub.occupy('carol', 'direct', sender=FakeSender()) and not hub.occupy('carol', 'direct', sender=FakeSender()))
289
+
290
+ # metadata failure must not be required to call send() — inspect source
291
+ loop_src = pathlib.Path(DIR/'app.py').read_text(encoding='utf-8')
292
+ check('title/OAuth failure does not stop RTMPS path',
293
+ 'metadata automation unavailable' in loop_src and 'video continues via RTMPS' in loop_src)
294
+
295
+ print('%d passed, %d failed' % (passed, len(failed)))
296
+ if failed:
297
+ sys.exit(1)