Fix live source handover without interrupting the YouTube publisher
Browse filesStart the replacement reader after releasing its handover flag; cancel and join old readers exclusively. Handle CDN EOF/read failures inside the same FFmpeg, allow bounded first-byte grace, reuse recent compatible live observations and ignore 16x16 placeholder canvas dimensions. Preserve channel, user settings, events and Secrets. Regression coverage includes real HTTP/FFmpeg continuity; 367 checks passed.
- Dockerfile +2 -1
- README.md +13 -1
- ingest.py +1 -1
- relay.py +74 -26
- test_source_reconnect.py +295 -0
- verify.sh +1 -0
- youtube_manager.py +21 -6
Dockerfile
CHANGED
|
@@ -6,7 +6,7 @@ WORKDIR /app
|
|
| 6 |
COPY requirements.txt .
|
| 7 |
RUN pip install --no-cache-dir -U -r requirements.txt
|
| 8 |
COPY app.py relay.py youtube_api.py youtube_manager.py flv_bridge.py stream_monitor.py tiktok_auth.py ingest.py index.html ./
|
| 9 |
-
COPY verify.sh verify_tokens.py probe_live.py test_watchdog.py test_lifecycle.py test_relay.py test_concurrency.py test_flv_bridge.py test_regressions.py test_completion.py test_channel_switch.py test_dashboard.js ./
|
| 10 |
|
| 11 |
# TikTok Live Monitor → Direct Relay → YouTube. No local video files.
|
| 12 |
ENV TZ=Asia/Baghdad \
|
|
@@ -47,6 +47,7 @@ ENV TZ=Asia/Baghdad \
|
|
| 47 |
YOUTUBE_FFMPEG_LOGLEVEL=info \
|
| 48 |
YOUTUBE_SOURCE_READ_TIMEOUT=15 \
|
| 49 |
YOUTUBE_SOURCE_READ_ATTEMPTS=3 \
|
|
|
|
| 50 |
YOUTUBE_PROGRESS_STALL_SECONDS=60 \
|
| 51 |
YOUTUBE_KILL_GRACE_SECONDS=10 \
|
| 52 |
YOUTUBE_PUMP_JOIN_SECONDS=2 \
|
|
|
|
| 6 |
COPY requirements.txt .
|
| 7 |
RUN pip install --no-cache-dir -U -r requirements.txt
|
| 8 |
COPY app.py relay.py youtube_api.py youtube_manager.py flv_bridge.py stream_monitor.py tiktok_auth.py ingest.py index.html ./
|
| 9 |
+
COPY verify.sh verify_tokens.py probe_live.py test_watchdog.py test_lifecycle.py test_relay.py test_source_reconnect.py test_concurrency.py test_flv_bridge.py test_regressions.py test_completion.py test_channel_switch.py test_dashboard.js ./
|
| 10 |
|
| 11 |
# TikTok Live Monitor → Direct Relay → YouTube. No local video files.
|
| 12 |
ENV TZ=Asia/Baghdad \
|
|
|
|
| 47 |
YOUTUBE_FFMPEG_LOGLEVEL=info \
|
| 48 |
YOUTUBE_SOURCE_READ_TIMEOUT=15 \
|
| 49 |
YOUTUBE_SOURCE_READ_ATTEMPTS=3 \
|
| 50 |
+
YOUTUBE_REATTACH_GRACE_SECONDS=2 \
|
| 51 |
YOUTUBE_PROGRESS_STALL_SECONDS=60 \
|
| 52 |
YOUTUBE_KILL_GRACE_SECONDS=10 \
|
| 53 |
YOUTUBE_PUMP_JOIN_SECONDS=2 \
|
README.md
CHANGED
|
@@ -8,7 +8,19 @@ pinned: false
|
|
| 8 |
app_port: 7860
|
| 9 |
---
|
| 10 |
|
| 11 |
-
# Reoo88/On — relay-v2.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
رصد TikTok LIVE → FFmpeg → YouTube RTMPS.
|
| 14 |
**لا تسجيل محلي، لا أرشيف فيديو، ولا ملفات فيديو على القرص.**
|
|
|
|
| 8 |
app_port: 7860
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# Reoo88/On — relay-v2.23-source-handover
|
| 12 |
+
|
| 13 |
+
## إصلاح انقطاع المصدر في v2.23
|
| 14 |
+
|
| 15 |
+
كان القارئ البديل يبدأ قبل مسح `swap_source`، فيخرج أحيانًا دون قراءة أي بايت، ثم يُعاد فتح المصدر مرارًا بينما يوتيوب يعرض `noData`. الآن يُوقف القارئ القديم أولًا، ولا يُعاد استعمال جسر FLV إذا بقي حيًا؛ تُمسح علامة التسليم قبل تشغيل القارئ الجديد. لكل قارئ إلغاء مستقل لمنع بايت متأخر من الجلسة السابقة.
|
| 16 |
+
|
| 17 |
+
- إغلاق CDN للاستجابة أو خطأ القراءة يجرّب مصدرًا بديلًا داخل **نفس FFmpeg ونفس حدث يوتيوب** قبل إعادة تشغيل الناشر.
|
| 18 |
+
- تُمنح الاستجابة الجديدة مهلة أول بايت قدرها ثانيتان (`YOUTUBE_REATTACH_GRACE_SECONDS`، حد أقصى 5 ث ونصف مهلة الصمت). لا يُحدّث عدّاد وصول البيانات وهميًا، ولا تُخفى أعطال خرج RTMP.
|
| 19 |
+
- أثناء إعادة التوصيل نجرّب رابطًا متوافق الترميز من رصد LIVE حديث (بحد أقصى 45 ث) بدل تجويع الناشر بانتظار طلبات بيانات تيك توك الطويلة. الرصد القديم أو غير المتوافق لا يُعتمد، وفحص انتهاء البث يبقى كما هو.
|
| 20 |
+
- ترويسة البداية `16×16` لا تثبّت كانفس التحويل الخفيف. عند غياب مقاس صالح يستعمل `matchgop` كانفس ثابتًا `720×1280`؛ أما مسار نسخ AVC/HEVC الأصلي فلا يُعاد ترميزه بسبب هذه الرقعة.
|
| 21 |
+
|
| 22 |
+
`test_source_reconnect.py` يشمل جدولة يبدأ فيها القارئ فورًا، وقارئًا قديمًا لم يتوقف، وإيقاف المستخدم، وعدم تزوير العمر، والترويسة الصغيرة، واختبار HTTP + FFmpeg حقيقيين: إغلاقات متكررة وتأخير أول بايت، مع استمرار الساعة بنفس عملية الإرسال في وضعي `copy` و`matchgop`. لا يستعمل الاختبار بثوثًا حقيقية.
|
| 23 |
+
|
| 24 |
|
| 25 |
رصد TikTok LIVE → FFmpeg → YouTube RTMPS.
|
| 26 |
**لا تسجيل محلي، لا أرشيف فيديو، ولا ملفات فيديو على القرص.**
|
ingest.py
CHANGED
|
@@ -6,7 +6,7 @@ import os, threading, time
|
|
| 6 |
from datetime import datetime
|
| 7 |
from zoneinfo import ZoneInfo
|
| 8 |
|
| 9 |
-
APP_VERSION = 'relay-v2.
|
| 10 |
|
| 11 |
|
| 12 |
def env_rtmp_destination():
|
|
|
|
| 6 |
from datetime import datetime
|
| 7 |
from zoneinfo import ZoneInfo
|
| 8 |
|
| 9 |
+
APP_VERSION = 'relay-v2.23-source-handover'
|
| 10 |
|
| 11 |
|
| 12 |
def env_rtmp_destination():
|
relay.py
CHANGED
|
@@ -351,7 +351,7 @@ class Sender:
|
|
| 351 |
an already-open live event is exactly the flash that only a YouTube-app restart
|
| 352 |
clears for existing viewers.
|
| 353 |
"""
|
| 354 |
-
if int(getattr(self, '_gop_w', 0) or 0) >=
|
| 355 |
return (self._gop_w, self._gop_h)
|
| 356 |
if candidate and not (w and h):
|
| 357 |
try:
|
|
@@ -364,7 +364,7 @@ class Sender:
|
|
| 364 |
except (TypeError, ValueError):
|
| 365 |
w = h = 0
|
| 366 |
w, h = w - w % 2, h - h % 2
|
| 367 |
-
if w >=
|
| 368 |
w, h = self.youtube_safe_size(w, h)
|
| 369 |
self._gop_w, self._gop_h = w, h
|
| 370 |
return (int(getattr(self, '_gop_w', 0) or 0), int(getattr(self, '_gop_h', 0) or 0))
|
|
@@ -381,8 +381,8 @@ class Sender:
|
|
| 381 |
w, h = int(w or 0), int(h or 0)
|
| 382 |
except (TypeError, ValueError):
|
| 383 |
return 0, 0
|
| 384 |
-
if w <
|
| 385 |
-
return
|
| 386 |
short, long_ = min(w, h), max(w, h)
|
| 387 |
portrait = h >= w
|
| 388 |
if short <= 720 and long_ >= 1280:
|
|
@@ -468,15 +468,12 @@ class Sender:
|
|
| 468 |
video=['-c:v','copy']
|
| 469 |
else:
|
| 470 |
if mode=='matchgop':
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
vf=self.canvas(cw,ch)
|
| 478 |
-
else:
|
| 479 |
-
vf='scale=trunc(iw/2)*2:trunc(ih/2)*2,setsar=1,format=yuv420p'
|
| 480 |
bitrate=os.getenv('VIDEO_BITRATE','').strip() or '4500k'
|
| 481 |
else:
|
| 482 |
width,height,bitrate=(1080,1920,'6000k') if mode=='fixed1080' else (720,1280,'4000k')
|
|
@@ -638,7 +635,7 @@ class Sender:
|
|
| 638 |
except BaseException as e: # تُعاد رفعها في خيط الضخّ لتُصنَّف هناك
|
| 639 |
out.put(e)
|
| 640 |
|
| 641 |
-
def _pump_source(self, response, p, bridge, stop, sess, read_timeout, retries):
|
| 642 |
"""نقل بايتات تيك توك إلى FFmpeg في خيط مستقل، مع تسجيل زمن آخر وصول فعلي.
|
| 643 |
|
| 644 |
القراءة هنا هي مصدر الحقيقة: `poll()` الخاص بـ FFmpeg يبقى ناجحًا وهو جالس ينتظر
|
|
@@ -649,7 +646,8 @@ class Sender:
|
|
| 649 |
chunk_wait = min(read_timeout, max(1.0, self._stall_seconds() / 3.0)) if self._stall_seconds() else read_timeout
|
| 650 |
pending = None
|
| 651 |
reader = None
|
| 652 |
-
while not stop.is_set() and not sess['force_stop'] and not sess.get('swap_source')
|
|
|
|
| 653 |
if time.monotonic() > deadline:
|
| 654 |
sess['error'] = 'انتهت مهلة الجلسة القصوى (%ds)' % int(sess['max_seconds'])
|
| 655 |
return
|
|
@@ -665,12 +663,17 @@ class Sender:
|
|
| 665 |
try:
|
| 666 |
got = pending.get(timeout=chunk_wait)
|
| 667 |
except queue.Empty:
|
|
|
|
|
|
|
| 668 |
silent = time.monotonic() - sess['last']
|
| 669 |
if self._stall_seconds() and silent > self._stall_seconds():
|
| 670 |
sess['error'] = 'صمت من تيك توك (%.0f ث بلا بايت)' % silent
|
| 671 |
return
|
| 672 |
continue
|
| 673 |
pending = None
|
|
|
|
|
|
|
|
|
|
| 674 |
if isinstance(got, BaseException):
|
| 675 |
e = got
|
| 676 |
if self._is_timeout(e) and not sess['force_stop']:
|
|
@@ -728,13 +731,17 @@ class Sender:
|
|
| 728 |
Returns (new_response, new_pump) or None. Viewer cuts come from killing FFmpeg, not from
|
| 729 |
a 1–2 s TikTok URL refresh into the same process.
|
| 730 |
"""
|
| 731 |
-
if not refresh or p is None or p.stdin is None or p.poll() is not None
|
|
|
|
| 732 |
return None
|
| 733 |
if int(sess.get('reattaches') or 0) >= 24:
|
| 734 |
return None
|
| 735 |
sess['swapping'] = True
|
| 736 |
sess['swap_source'] = True
|
| 737 |
sess['swap_started'] = time.monotonic()
|
|
|
|
|
|
|
|
|
|
| 738 |
try:
|
| 739 |
if response:
|
| 740 |
try:
|
|
@@ -743,12 +750,18 @@ class Sender:
|
|
| 743 |
pass
|
| 744 |
if pump is not None:
|
| 745 |
pump.join(timeout=float(os.getenv('YOUTUBE_PUMP_JOIN_SECONDS', '2')))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 746 |
try:
|
| 747 |
cand = refresh()
|
| 748 |
except Exception as e:
|
| 749 |
self.log(user, '[RELAY] تعذر رصد مصدر بديل دون قطع يوتيوب (%s)' % type(e).__name__)
|
| 750 |
return None
|
| 751 |
-
if not cand or cand.get('media') == 'hls' or not cand.get('url')
|
|
|
|
| 752 |
return None
|
| 753 |
try:
|
| 754 |
new_resp = requests.get(cand['url'],
|
|
@@ -759,19 +772,34 @@ class Sender:
|
|
| 759 |
except Exception as e:
|
| 760 |
self.log(user, '[RELAY] فشل فتح مصدر تيك توك البديل (%s)' % type(e).__name__)
|
| 761 |
return None
|
|
|
|
|
|
|
|
|
|
| 762 |
bridge.reconnect()
|
| 763 |
sess['ended'] = False
|
| 764 |
sess['error'] = ''
|
| 765 |
sess['stall_reason'] = ''
|
| 766 |
-
sess['force_stop'] = False
|
| 767 |
sess['timeouts'] = 0
|
| 768 |
# Do not bump sess['last'] here: that hid 40–57s of silence from the
|
| 769 |
# 12s stall watchdog (measured @vimtoo.2: out frozen, age 42–57, yh still good).
|
| 770 |
sess['reattaches'] = int(sess.get('reattaches') or 0) + 1
|
| 771 |
self.current_candidate = cand
|
| 772 |
self.set(source=cand.get('label') or cand.get('brief') or '', stalled=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 773 |
new_pump = threading.Thread(target=self._pump_source,
|
| 774 |
-
args=(new_resp, p, bridge, stop, sess, read_timeout, retries),
|
| 775 |
daemon=True)
|
| 776 |
new_pump.start()
|
| 777 |
self.log(user, '[RELAY] أُعيد توصيل تيك توك على نفس جلسة يوتيوب — بلا قتل FFmpeg')
|
|
@@ -970,8 +998,9 @@ class Sender:
|
|
| 970 |
threading.Thread(target=read_progress, daemon=True).start()
|
| 971 |
pump = None
|
| 972 |
if response is not None:
|
|
|
|
| 973 |
pump = threading.Thread(target=self._pump_source,
|
| 974 |
-
args=(response, p, bridge, stop, sess, read_timeout, retries),
|
| 975 |
daemon=True)
|
| 976 |
pump.start()
|
| 977 |
# ---- الحارس: لا تعتمد على poll() ولا على رمز الخروج ----
|
|
@@ -986,6 +1015,20 @@ class Sender:
|
|
| 986 |
# في HLS يستحيل القياس إلا على FFmpeg (هو من يفتح القائمة)، فنقبل تقدّمه.
|
| 987 |
_fresh = (sess['ff_last'] if response is None else sess['last'])
|
| 988 |
swap_for = (now - float(sess.get('swap_started') or 0)) if sess.get('swapping') else 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 989 |
# swapping must not disable the stall watchdog for longer than the stall
|
| 990 |
# limit: refresh()+HTTP open was measured at 40–57s of frozen viewers.
|
| 991 |
if self._recovering_copy:
|
|
@@ -1001,21 +1044,26 @@ class Sender:
|
|
| 1001 |
self.sticky_mode=None
|
| 1002 |
self.sticky_since=0.0
|
| 1003 |
self._recovering_copy=False
|
| 1004 |
-
if (stall_limit and now - _fresh > 3 and not
|
|
|
|
|
|
|
| 1005 |
and not sess.get('swap_source') and not sess.get('writing')
|
| 1006 |
and not sess.get('output_failure')):
|
|
|
|
| 1007 |
attached = self._try_reattach(user, p, bridge, sess, stop, refresh,
|
| 1008 |
read_timeout, retries, response, pump)
|
| 1009 |
if attached:
|
| 1010 |
response, pump = attached
|
| 1011 |
continue
|
| 1012 |
-
if stall_limit and now - _fresh > stall_limit and
|
|
|
|
|
|
|
| 1013 |
# A full stdin pipe proves the source delivered a chunk but FFmpeg stopped
|
| 1014 |
# consuming it. Reopening TikTok cannot repair a blocked RTMPS output.
|
| 1015 |
if sess.get('writing') or sess.get('output_failure'):
|
| 1016 |
self.last_failure_side = 'output'
|
| 1017 |
sess['output_failure'] = True
|
| 1018 |
-
|
| 1019 |
attached = self._try_reattach(user, p, bridge, sess, stop, refresh,
|
| 1020 |
read_timeout, retries, response, pump)
|
| 1021 |
if attached:
|
|
@@ -1051,10 +1099,10 @@ class Sender:
|
|
| 1051 |
break
|
| 1052 |
if now - progress['last'] > progress_limit:
|
| 1053 |
raise SourceStalled('لم يتقدّم FFmpeg منذ %.0f ث' % (now - progress['last']))
|
| 1054 |
-
if sess['ended'] and not sess['error']:
|
| 1055 |
self.log(user, '[MONITOR] أغلق تيك توك الاستجابة؛ الجلسة تُعاد من جديد')
|
| 1056 |
break
|
| 1057 |
-
if sess['error']:
|
| 1058 |
raise SourceStalled(sess['error'])
|
| 1059 |
if response is not None and now >= next_report:
|
| 1060 |
# بمعدل مضبوط: لا logging لكل بايت (كان سيملأ السجل في دقائق).
|
|
@@ -1062,7 +1110,7 @@ class Sender:
|
|
| 1062 |
self.log(user, '[WATCHDOG] المصدر يعمل؛ آخر بايت قبل %.0f ث، خرج %.1f ث'
|
| 1063 |
% (now - sess['last'], max(0, progress['seconds'])))
|
| 1064 |
if pump is not None and not pump.is_alive() and response is not None and not stop.is_set():
|
| 1065 |
-
if sess['force_stop'] or sess['error'] or sess['ended']:
|
| 1066 |
break
|
| 1067 |
time.sleep(1)
|
| 1068 |
except (OSError, ValueError) as e:
|
|
|
|
| 351 |
an already-open live event is exactly the flash that only a YouTube-app restart
|
| 352 |
clears for existing viewers.
|
| 353 |
"""
|
| 354 |
+
if int(getattr(self, '_gop_w', 0) or 0) >= 32:
|
| 355 |
return (self._gop_w, self._gop_h)
|
| 356 |
if candidate and not (w and h):
|
| 357 |
try:
|
|
|
|
| 364 |
except (TypeError, ValueError):
|
| 365 |
w = h = 0
|
| 366 |
w, h = w - w % 2, h - h % 2
|
| 367 |
+
if w >= 32 and h >= 32:
|
| 368 |
w, h = self.youtube_safe_size(w, h)
|
| 369 |
self._gop_w, self._gop_h = w, h
|
| 370 |
return (int(getattr(self, '_gop_w', 0) or 0), int(getattr(self, '_gop_h', 0) or 0))
|
|
|
|
| 381 |
w, h = int(w or 0), int(h or 0)
|
| 382 |
except (TypeError, ValueError):
|
| 383 |
return 0, 0
|
| 384 |
+
if w < 32 or h < 32:
|
| 385 |
+
return 0, 0 # TikTok may begin with a 16x16 placeholder configuration.
|
| 386 |
short, long_ = min(w, h), max(w, h)
|
| 387 |
portrait = h >= w
|
| 388 |
if short <= 720 and long_ >= 1280:
|
|
|
|
| 468 |
video=['-c:v','copy']
|
| 469 |
else:
|
| 470 |
if mode=='matchgop':
|
| 471 |
+
self.lock_gold(candidate)
|
| 472 |
+
if not self._gop_w or not self._gop_h:
|
| 473 |
+
# A 16x16 startup header is not the player's real canvas. Unknown
|
| 474 |
+
# dimensions still need a constant output, not a reinitializing scale.
|
| 475 |
+
self._gop_w,self._gop_h=720,1280
|
| 476 |
+
vf=self.canvas(self._gop_w,self._gop_h)
|
|
|
|
|
|
|
|
|
|
| 477 |
bitrate=os.getenv('VIDEO_BITRATE','').strip() or '4500k'
|
| 478 |
else:
|
| 479 |
width,height,bitrate=(1080,1920,'6000k') if mode=='fixed1080' else (720,1280,'4000k')
|
|
|
|
| 635 |
except BaseException as e: # تُعاد رفعها في خيط الضخّ لتُصنَّف هناك
|
| 636 |
out.put(e)
|
| 637 |
|
| 638 |
+
def _pump_source(self, response, p, bridge, stop, sess, read_timeout, retries, cancel=None):
|
| 639 |
"""نقل بايتات تيك توك إلى FFmpeg في خيط مستقل، مع تسجيل زمن آخر وصول فعلي.
|
| 640 |
|
| 641 |
القراءة هنا هي مصدر الحقيقة: `poll()` الخاص بـ FFmpeg يبقى ناجحًا وهو جالس ينتظر
|
|
|
|
| 646 |
chunk_wait = min(read_timeout, max(1.0, self._stall_seconds() / 3.0)) if self._stall_seconds() else read_timeout
|
| 647 |
pending = None
|
| 648 |
reader = None
|
| 649 |
+
while (not stop.is_set() and not sess['force_stop'] and not sess.get('swap_source')
|
| 650 |
+
and not (cancel and cancel.is_set())):
|
| 651 |
if time.monotonic() > deadline:
|
| 652 |
sess['error'] = 'انتهت مهلة الجلسة القصوى (%ds)' % int(sess['max_seconds'])
|
| 653 |
return
|
|
|
|
| 663 |
try:
|
| 664 |
got = pending.get(timeout=chunk_wait)
|
| 665 |
except queue.Empty:
|
| 666 |
+
if stop.is_set() or sess['force_stop'] or sess.get('swap_source') or (cancel and cancel.is_set()):
|
| 667 |
+
return
|
| 668 |
silent = time.monotonic() - sess['last']
|
| 669 |
if self._stall_seconds() and silent > self._stall_seconds():
|
| 670 |
sess['error'] = 'صمت من تيك توك (%.0f ث بلا بايت)' % silent
|
| 671 |
return
|
| 672 |
continue
|
| 673 |
pending = None
|
| 674 |
+
# A late result from the old HTTP reader cannot mutate the replacement pump.
|
| 675 |
+
if stop.is_set() or sess['force_stop'] or sess.get('swap_source') or (cancel and cancel.is_set()):
|
| 676 |
+
return
|
| 677 |
if isinstance(got, BaseException):
|
| 678 |
e = got
|
| 679 |
if self._is_timeout(e) and not sess['force_stop']:
|
|
|
|
| 731 |
Returns (new_response, new_pump) or None. Viewer cuts come from killing FFmpeg, not from
|
| 732 |
a 1–2 s TikTok URL refresh into the same process.
|
| 733 |
"""
|
| 734 |
+
if (not refresh or p is None or p.stdin is None or p.poll() is not None
|
| 735 |
+
or stop.is_set() or sess.get('force_stop')):
|
| 736 |
return None
|
| 737 |
if int(sess.get('reattaches') or 0) >= 24:
|
| 738 |
return None
|
| 739 |
sess['swapping'] = True
|
| 740 |
sess['swap_source'] = True
|
| 741 |
sess['swap_started'] = time.monotonic()
|
| 742 |
+
old_cancel = sess.get('pump_cancel')
|
| 743 |
+
if old_cancel is not None:
|
| 744 |
+
old_cancel.set()
|
| 745 |
try:
|
| 746 |
if response:
|
| 747 |
try:
|
|
|
|
| 750 |
pass
|
| 751 |
if pump is not None:
|
| 752 |
pump.join(timeout=float(os.getenv('YOUTUBE_PUMP_JOIN_SECONDS', '2')))
|
| 753 |
+
if pump.is_alive():
|
| 754 |
+
self.log(user, '[RELAY] قارئ المصدر السابق لم يتوقف؛ لا نشارك معه الجسر أو أنبوب FFmpeg')
|
| 755 |
+
return None
|
| 756 |
+
if stop.is_set() or sess.get('force_stop'):
|
| 757 |
+
return None
|
| 758 |
try:
|
| 759 |
cand = refresh()
|
| 760 |
except Exception as e:
|
| 761 |
self.log(user, '[RELAY] تعذر رصد مصدر بديل دون قطع يوتيوب (%s)' % type(e).__name__)
|
| 762 |
return None
|
| 763 |
+
if (not cand or cand.get('media') == 'hls' or not cand.get('url')
|
| 764 |
+
or stop.is_set() or sess.get('force_stop') or p.poll() is not None):
|
| 765 |
return None
|
| 766 |
try:
|
| 767 |
new_resp = requests.get(cand['url'],
|
|
|
|
| 772 |
except Exception as e:
|
| 773 |
self.log(user, '[RELAY] فشل فتح مصدر تيك توك البديل (%s)' % type(e).__name__)
|
| 774 |
return None
|
| 775 |
+
if stop.is_set() or sess.get('force_stop') or p.poll() is not None:
|
| 776 |
+
new_resp.close()
|
| 777 |
+
return None
|
| 778 |
bridge.reconnect()
|
| 779 |
sess['ended'] = False
|
| 780 |
sess['error'] = ''
|
| 781 |
sess['stall_reason'] = ''
|
|
|
|
| 782 |
sess['timeouts'] = 0
|
| 783 |
# Do not bump sess['last'] here: that hid 40–57s of silence from the
|
| 784 |
# 12s stall watchdog (measured @vimtoo.2: out frozen, age 42–57, yh still good).
|
| 785 |
sess['reattaches'] = int(sess.get('reattaches') or 0) + 1
|
| 786 |
self.current_candidate = cand
|
| 787 |
self.set(source=cand.get('label') or cand.get('brief') or '', stalled=False)
|
| 788 |
+
try:
|
| 789 |
+
grace = max(0.5, min(5.0, float(os.getenv('YOUTUBE_REATTACH_GRACE_SECONDS', '2'))))
|
| 790 |
+
except (TypeError, ValueError):
|
| 791 |
+
grace = 2.0
|
| 792 |
+
limit = self._stall_seconds()
|
| 793 |
+
if limit:
|
| 794 |
+
grace = min(grace, max(0.5, limit / 2.0))
|
| 795 |
+
sess['reattach_not_before'] = time.monotonic() + grace
|
| 796 |
+
new_cancel = threading.Event()
|
| 797 |
+
sess['pump_cancel'] = new_cancel
|
| 798 |
+
# Clear the OLD reader's handover flag BEFORE starting its replacement. If
|
| 799 |
+
# start() schedules the new target immediately, a true flag kills it unread.
|
| 800 |
+
sess['swap_source'] = False
|
| 801 |
new_pump = threading.Thread(target=self._pump_source,
|
| 802 |
+
args=(new_resp, p, bridge, stop, sess, read_timeout, retries, new_cancel),
|
| 803 |
daemon=True)
|
| 804 |
new_pump.start()
|
| 805 |
self.log(user, '[RELAY] أُعيد توصيل تيك توك على نفس جلسة يوتيوب — بلا قتل FFmpeg')
|
|
|
|
| 998 |
threading.Thread(target=read_progress, daemon=True).start()
|
| 999 |
pump = None
|
| 1000 |
if response is not None:
|
| 1001 |
+
sess['pump_cancel'] = threading.Event()
|
| 1002 |
pump = threading.Thread(target=self._pump_source,
|
| 1003 |
+
args=(response, p, bridge, stop, sess, read_timeout, retries, sess['pump_cancel']),
|
| 1004 |
daemon=True)
|
| 1005 |
pump.start()
|
| 1006 |
# ---- الحارس: لا تعتمد على poll() ولا على رمز الخروج ----
|
|
|
|
| 1015 |
# في HLS يستحيل القياس إلا على FFmpeg (هو من يفتح القائمة)، فنقبل تقدّمه.
|
| 1016 |
_fresh = (sess['ff_last'] if response is None else sess['last'])
|
| 1017 |
swap_for = (now - float(sess.get('swap_started') or 0)) if sess.get('swapping') else 0.0
|
| 1018 |
+
# Give a newly opened HTTP source time to deliver its first actual bytes.
|
| 1019 |
+
# Do not alter sess['last']: source age remains honest while we wait.
|
| 1020 |
+
recent_attach = now < float(sess.get('reattach_not_before') or 0)
|
| 1021 |
+
tried_reattach = False
|
| 1022 |
+
source_done = response is not None and (sess.get('ended') or sess.get('error') or
|
| 1023 |
+
(pump is not None and not pump.is_alive()))
|
| 1024 |
+
if (source_done and not recent_attach and not sess.get('force_stop')
|
| 1025 |
+
and not sess.get('writing') and not sess.get('output_failure')):
|
| 1026 |
+
tried_reattach = True
|
| 1027 |
+
attached = self._try_reattach(user, p, bridge, sess, stop, refresh,
|
| 1028 |
+
read_timeout, retries, response, pump)
|
| 1029 |
+
if attached:
|
| 1030 |
+
response, pump = attached
|
| 1031 |
+
continue
|
| 1032 |
# swapping must not disable the stall watchdog for longer than the stall
|
| 1033 |
# limit: refresh()+HTTP open was measured at 40–57s of frozen viewers.
|
| 1034 |
if self._recovering_copy:
|
|
|
|
| 1044 |
self.sticky_mode=None
|
| 1045 |
self.sticky_since=0.0
|
| 1046 |
self._recovering_copy=False
|
| 1047 |
+
if (stall_limit and now - _fresh > 3 and not recent_attach and not tried_reattach
|
| 1048 |
+
and not sess.get('force_stop')
|
| 1049 |
+
and not sess.get('swapping')
|
| 1050 |
and not sess.get('swap_source') and not sess.get('writing')
|
| 1051 |
and not sess.get('output_failure')):
|
| 1052 |
+
tried_reattach = True
|
| 1053 |
attached = self._try_reattach(user, p, bridge, sess, stop, refresh,
|
| 1054 |
read_timeout, retries, response, pump)
|
| 1055 |
if attached:
|
| 1056 |
response, pump = attached
|
| 1057 |
continue
|
| 1058 |
+
if (stall_limit and now - _fresh > stall_limit and
|
| 1059 |
+
(not recent_attach or sess.get('output_failure') or sess.get('writing')) and
|
| 1060 |
+
(not sess.get('swapping') or swap_for > stall_limit)):
|
| 1061 |
# A full stdin pipe proves the source delivered a chunk but FFmpeg stopped
|
| 1062 |
# consuming it. Reopening TikTok cannot repair a blocked RTMPS output.
|
| 1063 |
if sess.get('writing') or sess.get('output_failure'):
|
| 1064 |
self.last_failure_side = 'output'
|
| 1065 |
sess['output_failure'] = True
|
| 1066 |
+
elif not tried_reattach:
|
| 1067 |
attached = self._try_reattach(user, p, bridge, sess, stop, refresh,
|
| 1068 |
read_timeout, retries, response, pump)
|
| 1069 |
if attached:
|
|
|
|
| 1099 |
break
|
| 1100 |
if now - progress['last'] > progress_limit:
|
| 1101 |
raise SourceStalled('لم يتقدّم FFmpeg منذ %.0f ث' % (now - progress['last']))
|
| 1102 |
+
if sess['ended'] and not sess['error'] and not recent_attach:
|
| 1103 |
self.log(user, '[MONITOR] أغلق تيك توك الاستجابة؛ الجلسة تُعاد من جديد')
|
| 1104 |
break
|
| 1105 |
+
if sess['error'] and not recent_attach:
|
| 1106 |
raise SourceStalled(sess['error'])
|
| 1107 |
if response is not None and now >= next_report:
|
| 1108 |
# بمعدل مضبوط: لا logging لكل بايت (كان سيملأ السجل في دقائق).
|
|
|
|
| 1110 |
self.log(user, '[WATCHDOG] المصدر يعمل؛ آخر بايت قبل %.0f ث، خرج %.1f ث'
|
| 1111 |
% (now - sess['last'], max(0, progress['seconds'])))
|
| 1112 |
if pump is not None and not pump.is_alive() and response is not None and not stop.is_set():
|
| 1113 |
+
if sess['force_stop'] or ((sess['error'] or sess['ended']) and not recent_attach):
|
| 1114 |
break
|
| 1115 |
time.sleep(1)
|
| 1116 |
except (OSError, ValueError) as e:
|
test_source_reconnect.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Regression for live source handover; no real TikTok/YouTube requests.
|
| 3 |
+
|
| 4 |
+
Starting a replacement pump while swap_source=True made the new thread exit before
|
| 5 |
+
reading a single byte. FFmpeg was kept alive but YouTube received no replacement media.
|
| 6 |
+
"""
|
| 7 |
+
import io
|
| 8 |
+
import http.server
|
| 9 |
+
import json
|
| 10 |
+
import shutil
|
| 11 |
+
import subprocess
|
| 12 |
+
import tempfile
|
| 13 |
+
import os
|
| 14 |
+
import pathlib
|
| 15 |
+
import sys
|
| 16 |
+
import threading
|
| 17 |
+
import time
|
| 18 |
+
import unittest
|
| 19 |
+
from unittest.mock import Mock, patch
|
| 20 |
+
|
| 21 |
+
ROOT = pathlib.Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else pathlib.Path(__file__).parent
|
| 22 |
+
sys.path.insert(0, str(ROOT))
|
| 23 |
+
os.environ.setdefault('DATA_DIR', tempfile.mkdtemp(prefix='handover-test-data-'))
|
| 24 |
+
from relay import Sender
|
| 25 |
+
from youtube_manager import ManagedYouTube
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class InlineThread:
|
| 29 |
+
"""Deterministic scheduler: the new pump runs immediately when start() is called."""
|
| 30 |
+
def __init__(self, target, args=(), daemon=False):
|
| 31 |
+
self.target = target
|
| 32 |
+
self.args = args
|
| 33 |
+
self.daemon = daemon
|
| 34 |
+
self.started = False
|
| 35 |
+
|
| 36 |
+
def start(self):
|
| 37 |
+
self.started = True
|
| 38 |
+
self.target(*self.args)
|
| 39 |
+
|
| 40 |
+
def join(self, timeout=None):
|
| 41 |
+
pass
|
| 42 |
+
|
| 43 |
+
def is_alive(self):
|
| 44 |
+
return False
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class Response:
|
| 48 |
+
def __init__(self, chunks=(b'replacement media',)):
|
| 49 |
+
self.chunks = chunks
|
| 50 |
+
self.closed = False
|
| 51 |
+
self.reads = 0
|
| 52 |
+
|
| 53 |
+
def raise_for_status(self):
|
| 54 |
+
pass
|
| 55 |
+
|
| 56 |
+
def iter_content(self, chunk_size=None):
|
| 57 |
+
for chunk in self.chunks:
|
| 58 |
+
self.reads += 1
|
| 59 |
+
yield chunk
|
| 60 |
+
|
| 61 |
+
def close(self):
|
| 62 |
+
self.closed = True
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class HandoverTests(unittest.TestCase):
|
| 66 |
+
def setUp(self):
|
| 67 |
+
self.env = patch.dict(os.environ, {'SOURCE_STALL_TIMEOUT': '0'})
|
| 68 |
+
self.env.start()
|
| 69 |
+
self.addCleanup(self.env.stop)
|
| 70 |
+
self.sender = Sender('/bin/true', lambda *a: None)
|
| 71 |
+
self.p = Mock()
|
| 72 |
+
self.p.poll.return_value = None
|
| 73 |
+
self.p.stdin = io.BytesIO()
|
| 74 |
+
self.old_pump = Mock()
|
| 75 |
+
self.old_pump.is_alive.return_value = False
|
| 76 |
+
self.old_response = Response()
|
| 77 |
+
self.new_response = Response()
|
| 78 |
+
self.stop = threading.Event()
|
| 79 |
+
self.sess = {'last': time.monotonic() - 4, 'max_seconds': 180,
|
| 80 |
+
'force_stop': False, 'timeouts': 0, 'error': '', 'ended': True}
|
| 81 |
+
self.bridge = Mock()
|
| 82 |
+
self.bridge.feed.side_effect = lambda chunk: chunk
|
| 83 |
+
self.refresh = Mock(return_value={'url': 'https://example.invalid/source.flv', 'codec': 'h264', 'label': 'hd'})
|
| 84 |
+
|
| 85 |
+
def attach(self, refresh=None):
|
| 86 |
+
with patch('relay.requests.get', return_value=self.new_response), patch('relay.threading.Thread', InlineThread):
|
| 87 |
+
return self.sender._try_reattach('alice', self.p, self.bridge, self.sess,
|
| 88 |
+
self.stop, refresh or self.refresh, 10, 3, self.old_response, self.old_pump)
|
| 89 |
+
|
| 90 |
+
def test_new_pump_reads_media_even_when_scheduled_immediately(self):
|
| 91 |
+
attached = self.attach()
|
| 92 |
+
self.assertIsNotNone(attached)
|
| 93 |
+
self.assertEqual(self.new_response.reads, 1)
|
| 94 |
+
self.assertEqual(self.p.stdin.getvalue(), b'replacement media')
|
| 95 |
+
self.assertEqual(self.sender.snapshot()['source_bytes'], len(b'replacement media'))
|
| 96 |
+
self.assertFalse(self.sess['swap_source'])
|
| 97 |
+
|
| 98 |
+
def test_old_pump_must_stop_before_bridge_is_reused(self):
|
| 99 |
+
self.old_pump.is_alive.return_value = True
|
| 100 |
+
self.assertIsNone(self.attach())
|
| 101 |
+
self.bridge.reconnect.assert_not_called()
|
| 102 |
+
self.refresh.assert_not_called()
|
| 103 |
+
self.assertEqual(self.new_response.reads, 0)
|
| 104 |
+
|
| 105 |
+
def test_stop_during_resolution_does_not_resurrect_the_reader(self):
|
| 106 |
+
def stop_then_refresh():
|
| 107 |
+
self.stop.set()
|
| 108 |
+
return self.refresh()
|
| 109 |
+
self.assertIsNone(self.attach(stop_then_refresh))
|
| 110 |
+
self.bridge.reconnect.assert_not_called()
|
| 111 |
+
self.assertEqual(self.new_response.reads, 0)
|
| 112 |
+
|
| 113 |
+
def test_refresh_does_not_fabricate_source_heartbeat(self):
|
| 114 |
+
self.new_response = Response(chunks=())
|
| 115 |
+
previous = self.sess['last']
|
| 116 |
+
self.assertIsNotNone(self.attach())
|
| 117 |
+
self.assertEqual(self.sess['last'], previous)
|
| 118 |
+
self.assertEqual(self.sender.snapshot()['source_bytes'], 0)
|
| 119 |
+
|
| 120 |
+
def test_opened_source_has_bounded_first_byte_grace(self):
|
| 121 |
+
previous = self.sess['last']
|
| 122 |
+
now = time.monotonic()
|
| 123 |
+
self.new_response = Response(chunks=())
|
| 124 |
+
self.attach()
|
| 125 |
+
self.assertGreater(self.sess['reattach_not_before'], now)
|
| 126 |
+
self.assertLessEqual(self.sess['reattach_not_before'], time.monotonic() + 5)
|
| 127 |
+
self.assertEqual(self.sess['last'], previous)
|
| 128 |
+
|
| 129 |
+
def test_cancelled_reader_cannot_deliver_a_late_chunk(self):
|
| 130 |
+
cancel = threading.Event()
|
| 131 |
+
response = Response()
|
| 132 |
+
def late_chunk(_size=None):
|
| 133 |
+
cancel.set()
|
| 134 |
+
yield b'old reader late packet'
|
| 135 |
+
response.iter_content = late_chunk
|
| 136 |
+
with patch('relay.threading.Thread', InlineThread):
|
| 137 |
+
self.sender._pump_source(response, self.p, self.bridge, self.stop,
|
| 138 |
+
self.sess, 10, 3, cancel)
|
| 139 |
+
self.assertEqual(self.p.stdin.getvalue(), b'')
|
| 140 |
+
self.assertEqual(self.sender.snapshot()['source_bytes'], 0)
|
| 141 |
+
|
| 142 |
+
def test_no_source_is_a_failed_handover_not_a_fake_success(self):
|
| 143 |
+
self.refresh.return_value = None
|
| 144 |
+
self.assertIsNone(self.attach())
|
| 145 |
+
self.bridge.reconnect.assert_not_called()
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
class CanvasTests(unittest.TestCase):
|
| 149 |
+
def test_placeholder_16x16_does_not_pin_the_live_canvas(self):
|
| 150 |
+
sender = Sender('/bin/true', lambda *a: None)
|
| 151 |
+
self.assertEqual(sender.lock_gold({'width': 16, 'height': 16}), (0, 0))
|
| 152 |
+
self.assertEqual(sender.lock_gold({'width': 720, 'height': 1440}), (720, 1280))
|
| 153 |
+
|
| 154 |
+
def test_unknown_or_placeholder_matchgop_uses_safe_constant_canvas(self):
|
| 155 |
+
for candidate in ({}, {'width': 16, 'height': 16}):
|
| 156 |
+
sender = Sender('/bin/true', lambda *a: None)
|
| 157 |
+
sender.mode_override = 'matchgop'
|
| 158 |
+
args = sender.command('test.flv', candidate)
|
| 159 |
+
vf = args[args.index('-vf') + 1]
|
| 160 |
+
self.assertIn('scale=w=720:h=1280:', vf)
|
| 161 |
+
# A later real configuration does not mutate the encoder canvas.
|
| 162 |
+
sender.lock_gold({'width': 960, 'height': 1920})
|
| 163 |
+
self.assertEqual((sender._gop_w, sender._gop_h), (720, 1280))
|
| 164 |
+
|
| 165 |
+
def test_normal_and_real_1080_source_dimensions_remain_supported(self):
|
| 166 |
+
for size in ((160, 120), (320, 240), (1080, 1920)):
|
| 167 |
+
sender = Sender('/bin/true', lambda *a: None)
|
| 168 |
+
self.assertEqual(sender.lock_gold({'width': size[0], 'height': size[1]}), size)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
class CachedSourceTests(unittest.TestCase):
|
| 172 |
+
def setUp(self):
|
| 173 |
+
self.tmp = tempfile.TemporaryDirectory()
|
| 174 |
+
self.addCleanup(self.tmp.cleanup)
|
| 175 |
+
self.manager = ManagedYouTube('/bin/true', lambda u, force=False: None,
|
| 176 |
+
lambda *a: None, lambda: ['alice'], pathlib.Path(self.tmp.name) / 'journal.json')
|
| 177 |
+
self.manager.video_sources = lambda sources: list(sources)
|
| 178 |
+
self.hd = {'url': 'https://example.invalid/hd.flv', 'media': 'flv', 'codec': 'h264',
|
| 179 |
+
'quality_name': 'hd', 'width': 720, 'height': 1280, 'label': 'hd'}
|
| 180 |
+
self.sd = dict(self.hd, url='https://example.invalid/sd.flv', quality_name='sd',
|
| 181 |
+
width=480, height=854, label='sd')
|
| 182 |
+
self.obs = {'status': 'live', 'room_id': 'same-room', 'sources': [self.hd, self.sd]}
|
| 183 |
+
self.manager._observe = Mock(return_value=self.obs)
|
| 184 |
+
|
| 185 |
+
def test_recent_compatible_source_is_reused_without_blocking_metadata_lookup(self):
|
| 186 |
+
self.manager._held['alice'] = (time.monotonic(), self.obs)
|
| 187 |
+
chosen = self.manager._refresh_flv('alice', self.hd)
|
| 188 |
+
self.assertEqual(chosen['url'], self.sd['url'])
|
| 189 |
+
self.manager._observe.assert_not_called()
|
| 190 |
+
|
| 191 |
+
def test_stale_source_snapshot_requires_fresh_lookup(self):
|
| 192 |
+
self.manager._held['alice'] = (time.monotonic() - 60, self.obs)
|
| 193 |
+
self.assertEqual(self.manager._refresh_flv('alice', self.hd)['url'], self.sd['url'])
|
| 194 |
+
self.manager._observe.assert_called_once_with('alice', force=True, bypass_throttle=True, allow_hold=False)
|
| 195 |
+
|
| 196 |
+
def test_cache_with_wrong_codec_cannot_be_spliced_into_open_publisher(self):
|
| 197 |
+
self.manager._held['alice'] = (time.monotonic(), dict(self.obs, sources=[dict(self.sd, codec='h265')]))
|
| 198 |
+
chosen = self.manager._refresh_flv('alice', self.hd)
|
| 199 |
+
self.assertEqual(chosen['codec'], 'h264')
|
| 200 |
+
self.manager._observe.assert_called_once()
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
@unittest.skipUnless(shutil.which('ffmpeg') and shutil.which('ffprobe'), 'FFmpeg required')
|
| 204 |
+
class RealHTTPContinuityTests(unittest.TestCase):
|
| 205 |
+
def run_continuity(self, mode):
|
| 206 |
+
with tempfile.TemporaryDirectory(prefix='source-handover-media-') as tmp:
|
| 207 |
+
root = pathlib.Path(tmp)
|
| 208 |
+
source = root / 'generated.flv'
|
| 209 |
+
output = root / 'output.flv'
|
| 210 |
+
subprocess.run([shutil.which('ffmpeg'), '-v', 'error', '-y', '-f', 'lavfi',
|
| 211 |
+
'-i', 'testsrc2=size=160x120:rate=20', '-f', 'lavfi', '-i', 'sine=sample_rate=48000',
|
| 212 |
+
'-t', '2', '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p',
|
| 213 |
+
'-g', '20', '-c:a', 'aac', '-f', 'flv', str(source)], check=True, capture_output=True)
|
| 214 |
+
media = source.read_bytes()
|
| 215 |
+
calls = []
|
| 216 |
+
class Handler(http.server.BaseHTTPRequestHandler):
|
| 217 |
+
def log_message(self, *a):
|
| 218 |
+
pass
|
| 219 |
+
def do_GET(self):
|
| 220 |
+
calls.append(time.monotonic())
|
| 221 |
+
self.send_response(200)
|
| 222 |
+
self.send_header('Content-Type', 'video/x-flv')
|
| 223 |
+
self.send_header('Content-Length', str(len(media)))
|
| 224 |
+
self.end_headers()
|
| 225 |
+
# Header arrives immediately; replacements wait before their first byte.
|
| 226 |
+
# Without startup grace the supervisor closes them before this media arrives.
|
| 227 |
+
if len(calls) > 1:
|
| 228 |
+
time.sleep(1)
|
| 229 |
+
try:
|
| 230 |
+
self.wfile.write(media)
|
| 231 |
+
self.wfile.flush()
|
| 232 |
+
except (OSError, BrokenPipeError):
|
| 233 |
+
pass
|
| 234 |
+
server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
| 235 |
+
server.daemon_threads = True
|
| 236 |
+
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
|
| 237 |
+
server_thread.start()
|
| 238 |
+
stop = threading.Event()
|
| 239 |
+
errors = []
|
| 240 |
+
candidate = {'url': 'http://127.0.0.1:%d/source.flv' % server.server_port,
|
| 241 |
+
'media': 'flv', 'codec': 'h264', 'label': 'fixture', 'width': 160, 'height': 120}
|
| 242 |
+
sender = Sender(shutil.which('ffmpeg'), lambda *a: None)
|
| 243 |
+
sender.mode_override = mode
|
| 244 |
+
spawned = []
|
| 245 |
+
from relay import subprocess as relay_subprocess
|
| 246 |
+
real_popen = relay_subprocess.Popen
|
| 247 |
+
def track_process(*a, **kw):
|
| 248 |
+
child = real_popen(*a, **kw)
|
| 249 |
+
spawned.append(child)
|
| 250 |
+
return child
|
| 251 |
+
def send():
|
| 252 |
+
try:
|
| 253 |
+
sender.send('fixture', candidate, str(output), stop, refresh=lambda: dict(candidate))
|
| 254 |
+
except Exception as exc:
|
| 255 |
+
errors.append(exc)
|
| 256 |
+
env = {'SOURCE_STALL_TIMEOUT': '3', 'YOUTUBE_REATTACH_GRACE_SECONDS': '2',
|
| 257 |
+
'YOUTUBE_SOURCE_READ_TIMEOUT': '5', 'YOUTUBE_PUMP_JOIN_SECONDS': '2',
|
| 258 |
+
'YOUTUBE_SEND_MODE': mode, 'YOUTUBE_VIDEO_MODE': 'original',
|
| 259 |
+
'YOUTUBE_AUTO_TRANSCODE': '0', 'YOUTUBE_X264_PRESET': 'ultrafast'}
|
| 260 |
+
thread = None
|
| 261 |
+
try:
|
| 262 |
+
with patch.dict(os.environ, env), patch('relay.subprocess.Popen', side_effect=track_process):
|
| 263 |
+
thread = threading.Thread(target=send, daemon=True)
|
| 264 |
+
thread.start()
|
| 265 |
+
time.sleep(7.5)
|
| 266 |
+
alive = thread.is_alive() and sender.alive()
|
| 267 |
+
snapshot = sender.snapshot()
|
| 268 |
+
stop.set()
|
| 269 |
+
thread.join(6)
|
| 270 |
+
self.assertTrue(alive, 'Publisher ended instead of reusing the delayed replacement source: %r' % errors)
|
| 271 |
+
self.assertFalse(errors, repr(errors))
|
| 272 |
+
self.assertEqual(len(spawned), 1, 'Source reconnect must keep the same FFmpeg process')
|
| 273 |
+
self.assertGreaterEqual(len(calls), 3)
|
| 274 |
+
self.assertGreater(snapshot.get('output_seconds', 0), 4)
|
| 275 |
+
self.assertFalse(thread.is_alive(), 'Sender did not stop cleanly')
|
| 276 |
+
probe = subprocess.run([shutil.which('ffprobe'), '-v', 'error', '-show_entries',
|
| 277 |
+
'format=duration', '-of', 'json', str(output)], check=True, capture_output=True, text=True)
|
| 278 |
+
self.assertGreater(float(json.loads(probe.stdout)['format']['duration']), 4)
|
| 279 |
+
finally:
|
| 280 |
+
stop.set()
|
| 281 |
+
sender.terminate()
|
| 282 |
+
if thread:
|
| 283 |
+
thread.join(5)
|
| 284 |
+
server.shutdown()
|
| 285 |
+
server.server_close()
|
| 286 |
+
|
| 287 |
+
def test_eof_and_delayed_replacements_keep_same_copy_publisher(self):
|
| 288 |
+
self.run_continuity('copy')
|
| 289 |
+
|
| 290 |
+
def test_eof_and_delayed_replacements_keep_same_matchgop_publisher(self):
|
| 291 |
+
self.run_continuity('matchgop')
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
if __name__ == '__main__':
|
| 295 |
+
unittest.main(argv=[sys.argv[0]], verbosity=2)
|
verify.sh
CHANGED
|
@@ -26,6 +26,7 @@ else
|
|
| 26 |
fi
|
| 27 |
python3 test_regressions.py "$ROOT"
|
| 28 |
python3 test_relay.py "$ROOT"
|
|
|
|
| 29 |
python3 test_flv_bridge.py "$ROOT"
|
| 30 |
python3 test_completion.py "$ROOT"
|
| 31 |
# Optional heavy tests when ffmpeg exists
|
|
|
|
| 26 |
fi
|
| 27 |
python3 test_regressions.py "$ROOT"
|
| 28 |
python3 test_relay.py "$ROOT"
|
| 29 |
+
python3 test_source_reconnect.py "$ROOT"
|
| 30 |
python3 test_flv_bridge.py "$ROOT"
|
| 31 |
python3 test_completion.py "$ROOT"
|
| 32 |
# Optional heavy tests when ffmpeg exists
|
youtube_manager.py
CHANGED
|
@@ -1215,12 +1215,27 @@ class ManagedYouTube:
|
|
| 1215 |
YouTube. After a long stable interval, a later natural refresh may restore the preferred
|
| 1216 |
rendition. HLS and H.264↔H.265 changes cannot be spliced into this open FFmpeg map.
|
| 1217 |
"""
|
| 1218 |
-
|
| 1219 |
-
|
| 1220 |
-
|
| 1221 |
-
|
| 1222 |
-
|
| 1223 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1224 |
if (obs or {}).get('status')!='live':
|
| 1225 |
return None
|
| 1226 |
# We are already inside a three-second source gap. Probing every rendition serially can
|
|
|
|
| 1215 |
YouTube. After a long stable interval, a later natural refresh may restore the preferred
|
| 1216 |
rendition. HLS and H.264↔H.265 changes cannot be spliced into this open FFmpeg map.
|
| 1217 |
"""
|
| 1218 |
+
# A normal CDN EOF is not evidence that its signed URL expired. The manager
|
| 1219 |
+
# already refreshes live observations continuously. Try a recent verified same-codec
|
| 1220 |
+
# source first instead of blocking this publisher on 20s metadata requests while
|
| 1221 |
+
# YouTube starves. Stale/absent/incompatible snapshots still force a fresh lookup.
|
| 1222 |
+
obs=None
|
| 1223 |
+
held=self._held.get(user)
|
| 1224 |
+
family=self._codec_family(current)
|
| 1225 |
+
if held and self.hold_seconds>0 and time.monotonic()-held[0]<=min(45.0,self.hold_seconds):
|
| 1226 |
+
cached=held[1] or {}
|
| 1227 |
+
if cached.get('status')=='live' and any(
|
| 1228 |
+
c.get('url') and c.get('media')!='hls' and
|
| 1229 |
+
(not family or self._codec_family(c)==family)
|
| 1230 |
+
for c in self.video_sources(cached.get('sources') or [])):
|
| 1231 |
+
obs=cached
|
| 1232 |
+
if obs is None:
|
| 1233 |
+
self._held.pop(user, None)
|
| 1234 |
+
self._skip_hold.add(user)
|
| 1235 |
+
try:
|
| 1236 |
+
obs=self._observe(user, force=True, bypass_throttle=True, allow_hold=False)
|
| 1237 |
+
except Exception:
|
| 1238 |
+
return None
|
| 1239 |
if (obs or {}).get('status')!='live':
|
| 1240 |
return None
|
| 1241 |
# We are already inside a three-second source gap. Probing every rendition serially can
|