Reoo88 commited on
Commit
8ede3fd
·
verified ·
1 Parent(s): 6e58f52

Fix live source handover without interrupting the YouTube publisher

Browse files

Start 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.

Files changed (7) hide show
  1. Dockerfile +2 -1
  2. README.md +13 -1
  3. ingest.py +1 -1
  4. relay.py +74 -26
  5. test_source_reconnect.py +295 -0
  6. verify.sh +1 -0
  7. 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.22-channel-confirmation
 
 
 
 
 
 
 
 
 
 
 
 
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.22-channel-confirmation'
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) >= 16:
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 >= 16 and h >= 16:
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 < 16 or h < 16:
385
- return w, h
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
- w=int((candidate or {}).get('width') or 0); h=int((candidate or {}).get('height') or 0)
472
- w,h=w-w%2,h-h%2
473
- if not getattr(self,'_gop_w',0) and w>=16 and h>=16:
474
- self._gop_w,self._gop_h=w,h
475
- cw,ch=int(getattr(self,'_gop_w',0) or w), int(getattr(self,'_gop_h',0) or h)
476
- if cw>=16 and ch>=16:
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 sess.get('swapping')
 
 
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 (not sess.get('swapping') or swap_for > stall_limit):
 
 
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
- else:
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
- self._held.pop(user, None)
1219
- self._skip_hold.add(user)
1220
- try:
1221
- obs=self._observe(user, force=True, bypass_throttle=True, allow_hold=False)
1222
- except Exception:
1223
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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