Real YouTube loading fix: never delay copy reconnect once the event is live; dump_extra on H264 copy IDR
Browse files- relay.py +4 -0
- youtube_manager.py +40 -15
relay.py
CHANGED
|
@@ -341,6 +341,10 @@ class Sender:
|
|
| 341 |
enhanced=self.enhanced_advertisement(mode,candidate) if network else []
|
| 342 |
if mode=='copy':
|
| 343 |
video=['-c:v','copy']
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
else:
|
| 345 |
width,height,bitrate=(1080,1920,'6000k') if mode=='fixed1080' else (720,1280,'4000k')
|
| 346 |
# VIDEO_BITRATE was documented in the README but never read anywhere; it now caps the
|
|
|
|
| 341 |
enhanced=self.enhanced_advertisement(mode,candidate) if network else []
|
| 342 |
if mode=='copy':
|
| 343 |
video=['-c:v','copy']
|
| 344 |
+
# Repeat SPS/PPS on every IDR so a YouTube viewer who hits loading can
|
| 345 |
+
# resync without leaving the app. Video remains copy (no re-encode).
|
| 346 |
+
if not self.hevc_payload(candidate):
|
| 347 |
+
video += ['-bsf:v','dump_extra=freq=keyframe']
|
| 348 |
else:
|
| 349 |
width,height,bitrate=(1080,1920,'6000k') if mode=='fixed1080' else (720,1280,'4000k')
|
| 350 |
# VIDEO_BITRATE was documented in the README but never read anywhere; it now caps the
|
youtube_manager.py
CHANGED
|
@@ -698,11 +698,25 @@ class ManagedYouTube:
|
|
| 698 |
self.log(user or 'system', '[WATCHDOG] أُلغي تأجيل إعادة الاتصال (%d حدث) — %s' % (dropped, why))
|
| 699 |
return dropped
|
| 700 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 701 |
def start_sender(self,key):
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
# لأن
|
|
|
|
| 705 |
until=self.retry_at.get(key)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 706 |
if until and time.monotonic()<until:
|
| 707 |
left=int(round(until-time.monotonic()))
|
| 708 |
# الصمت هنا هو بالضبط «بالانتظار بلا سبب» الذي شكا منه المستخدم: القفل صحيح لكن يجب
|
|
@@ -766,7 +780,7 @@ class ManagedYouTube:
|
|
| 766 |
return
|
| 767 |
if hub.busy(ev['username']):
|
| 768 |
return
|
| 769 |
-
if time.monotonic()-float(self._spawn_at.get(ev['username']) or 0)<8:
|
| 770 |
return
|
| 771 |
# لا نلمس entry هنا: بعد موت خيط الإرسال تُبقي tick() القيد الميت في self.senders،
|
| 772 |
# و`entry['candidate']=None` على قيد ميت كان يرمي TypeError عند كل دورة مشغّل
|
|
@@ -812,7 +826,8 @@ class ManagedYouTube:
|
|
| 812 |
if not since:
|
| 813 |
entry['idle_since']=time.monotonic()
|
| 814 |
continue
|
| 815 |
-
if
|
|
|
|
| 816 |
continue
|
| 817 |
self.log(ev.get('username') or key,
|
| 818 |
'[RELAY] @%s sender thread without FFmpeg; recycle'%ev.get('username'))
|
|
@@ -959,7 +974,11 @@ class ManagedYouTube:
|
|
| 959 |
# Skip the idle wait right after a resolution change; otherwise keep polling.
|
| 960 |
if restart_at and time.monotonic()-restart_at<30:restart_at=0.0;continue
|
| 961 |
gap=float(getattr(sender,'last_source_gap',0) or 0)
|
| 962 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 963 |
if stop.wait(wait):return
|
| 964 |
def mark_stream_stable(self, user):
|
| 965 |
"""الحافة تقبل التدفق: تُرمى نافذة العقاب وملاحظتها وعدّادها. تُستدعى من المرسل عند أول
|
|
@@ -1054,15 +1073,21 @@ class ManagedYouTube:
|
|
| 1054 |
except Exception as e:
|
| 1055 |
self.log(user, '[WATCHDOG] تعذر تجديد عنوان الاستقبال: ' + self.safe_error(e)[:120])
|
| 1056 |
if n >= 3:
|
| 1057 |
-
|
| 1058 |
-
|
| 1059 |
-
|
| 1060 |
-
|
| 1061 |
-
|
| 1062 |
-
self.
|
| 1063 |
-
|
| 1064 |
-
|
| 1065 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1066 |
return
|
| 1067 |
|
| 1068 |
def _backoff(self,user):
|
|
|
|
| 698 |
self.log(user or 'system', '[WATCHDOG] أُلغي تأجيل إعادة الاتصال (%d حدث) — %s' % (dropped, why))
|
| 699 |
return dropped
|
| 700 |
|
| 701 |
+
|
| 702 |
+
def _yt_public(self, ev):
|
| 703 |
+
"""Viewers can already open the watch URL — any delay is a YouTube loading spinner."""
|
| 704 |
+
if not ev:
|
| 705 |
+
return False
|
| 706 |
+
if ev.get('was_live'):
|
| 707 |
+
return True
|
| 708 |
+
return str(ev.get('youtube_status') or '') in ('live', 'testing', 'liveStarting')
|
| 709 |
def start_sender(self,key):
|
| 710 |
+
ev=self.journal.read()['events'].get(key)
|
| 711 |
+
public=self._yt_public(ev)
|
| 712 |
+
# عقاب الوتيرة يجب أن يصمد أمام نقرات tick() قبل أن يصبح الحدث علنيًا.
|
| 713 |
+
# بعد live أي تأخير = دائرة تحميل أمام المشاهد — نلغي القفل ونعيد copy فورًا.
|
| 714 |
until=self.retry_at.get(key)
|
| 715 |
+
if public:
|
| 716 |
+
if until:
|
| 717 |
+
self.retry_at.pop(key, None)
|
| 718 |
+
self._gate_note_at.pop(key, None)
|
| 719 |
+
until=None
|
| 720 |
if until and time.monotonic()<until:
|
| 721 |
left=int(round(until-time.monotonic()))
|
| 722 |
# الصمت هنا هو بالضبط «بالانتظار بلا سبب» الذي شكا منه المستخدم: القفل صحيح لكن يجب
|
|
|
|
| 780 |
return
|
| 781 |
if hub.busy(ev['username']):
|
| 782 |
return
|
| 783 |
+
if (not public) and time.monotonic()-float(self._spawn_at.get(ev['username']) or 0)<8:
|
| 784 |
return
|
| 785 |
# لا نلمس entry هنا: بعد موت خيط الإرسال تُبقي tick() القيد الميت في self.senders،
|
| 786 |
# و`entry['candidate']=None` على قيد ميت كان يرمي TypeError عند كل دورة مشغّل
|
|
|
|
| 826 |
if not since:
|
| 827 |
entry['idle_since']=time.monotonic()
|
| 828 |
continue
|
| 829 |
+
grace=2.0 if self._yt_public(ev) else 20.0
|
| 830 |
+
if time.monotonic()-since<grace:
|
| 831 |
continue
|
| 832 |
self.log(ev.get('username') or key,
|
| 833 |
'[RELAY] @%s sender thread without FFmpeg; recycle'%ev.get('username'))
|
|
|
|
| 974 |
# Skip the idle wait right after a resolution change; otherwise keep polling.
|
| 975 |
if restart_at and time.monotonic()-restart_at<30:restart_at=0.0;continue
|
| 976 |
gap=float(getattr(sender,'last_source_gap',0) or 0)
|
| 977 |
+
ev_now=(self.journal.read()['events'].get(key) or {})
|
| 978 |
+
if self._yt_public(ev_now):
|
| 979 |
+
wait=0.5
|
| 980 |
+
else:
|
| 981 |
+
wait=0.5 if gap>=8 else self._backoff(user)
|
| 982 |
if stop.wait(wait):return
|
| 983 |
def mark_stream_stable(self, user):
|
| 984 |
"""الحافة تقبل التدفق: تُرمى نافذة العقاب وملاحظتها وعدّادها. تُستدعى من المرسل عند أول
|
|
|
|
| 1073 |
except Exception as e:
|
| 1074 |
self.log(user, '[WATCHDOG] تعذر تجديد عنوان الاستقبال: ' + self.safe_error(e)[:120])
|
| 1075 |
if n >= 3:
|
| 1076 |
+
public=any(self._yt_public(ev) for ev in _evs)
|
| 1077 |
+
if public:
|
| 1078 |
+
# الحدث أمام المشاهد: أي تأجيل = دائرة تحميل. نعيد copy على نفس المفتاح فورًا.
|
| 1079 |
+
for k in [k for k, ev in self.journal.read()['events'].items() if ev.get('username') == user]:
|
| 1080 |
+
self.retry_at.pop(k, None)
|
| 1081 |
+
self.retry[user] = self.retry_min
|
| 1082 |
+
self.log(user, '[WATCHDOG] يوتيوب أغلق RTMPS والحدث ما زال live — إعادة copy فورًا على نفس المفتاح (بلا تأجيل %d×)'%n)
|
| 1083 |
+
else:
|
| 1084 |
+
wait = min(120.0, max(20.0, self.retry_min) * (2 ** (n - 3)))
|
| 1085 |
+
self.retry[user] = max(self.retry.get(user, 0) or 0, wait)
|
| 1086 |
+
for k in [k for k, ev in self.journal.read()['events'].items() if ev.get('username') == user]:
|
| 1087 |
+
self.retry_at[k] = time.monotonic() + wait
|
| 1088 |
+
self.log(user, '[WATCHDOG] الوجهة أغلقت الاتصال بعد %.0f ث (رمز %s) — المرة %d: تأجيل إعادة الاتصال إلى %d ث بدل النقر على حافة ترفض%s'
|
| 1089 |
+
% (lasted, code, n, int(self._backoff(user)),
|
| 1090 |
+
'؛ السبب المعلوم: الحدث لم يُشغَّل بعد' if not_started else ''))
|
| 1091 |
return
|
| 1092 |
|
| 1093 |
def _backoff(self,user):
|