Reoo88 commited on
Commit
fe77a7a
·
verified ·
1 Parent(s): a9631e4

v2.41 dual simultaneous YouTube ingest (primary+backup tee fan-out)

Browse files
Files changed (5) hide show
  1. ingest.py +1 -1
  2. relay.py +51 -7
  3. test_ingest_routes.py +56 -0
  4. test_relay.py +1 -1
  5. youtube_manager.py +25 -1
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.40-avfill-splice'
10
 
11
 
12
  def env_rtmp_destination():
 
6
  from datetime import datetime
7
  from zoneinfo import ZoneInfo
8
 
9
+ APP_VERSION = 'relay-v2.41-dual-ingest'
10
 
11
 
12
  def env_rtmp_destination():
relay.py CHANGED
@@ -572,7 +572,38 @@ class Sender:
572
  return []
573
  return ['-rtmp_enhanced_codecs', 'hvc1'] if self.hevc_payload(candidate) else []
574
 
575
- def command(self,destination,candidate=None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
576
  # إعلان Enhanced-RTMP يُقاس على الترميز المدفوع فعلًا؛ ملفات الاختبار المحلية لا بروتوكول لها.
577
  mode=self.send_mode()
578
  self.active_mode=mode
@@ -665,11 +696,22 @@ class Sender:
665
  '-recovery_wait_time',os.getenv('YOUTUBE_RECOVERY_WAIT','1'),
666
  '-queue_size',str(q),'-timeshift',str(ts)]
667
  else:
668
- # N26: خرج مباشر بلا مخزن يكذب على الحارس — تقدم out_time هنا يعني تسليمًا فعليًا.
669
- output=['-flvflags','no_duration_filesize','-f','flv']
 
 
 
 
 
 
 
670
  else:
671
  output=['-flvflags','no_duration_filesize','-f','flv']
672
  ta=self.input_args(candidate)
 
 
 
 
673
  return [self.ffmpeg,'-hide_banner','-loglevel',('debug' if network and self.trace_rtmp else os.getenv('YOUTUBE_FFMPEG_LOGLEVEL','error')),
674
  '-nostats','-progress','pipe:1',
675
  *ta['input'],'-map','0:v:0','-map','0:a:0?',
@@ -680,7 +722,7 @@ class Sender:
680
  *video,'-c:a','aac','-profile:a',os.getenv('YOUTUBE_AUDIO_PROFILE','aac_low'),
681
  '-b:a',os.getenv('YOUTUBE_AUDIO_BITRATE','128k'),'-ar','48000','-ac',os.getenv('YOUTUBE_AUDIO_CHANNELS','2'),
682
  '-af','aresample=async=1:first_pts=0','-avoid_negative_ts','make_zero',
683
- *enhanced,'-rw_timeout','20000000',*output,destination]
684
  # ------- مهل المصدر (كلها من البيئة؛ القيم الافتراضية مبرَّرة أدناه) -------
685
  @property
686
  def stall_limit(self):
@@ -1115,7 +1157,7 @@ class Sender:
1115
  codes = [c for c in codes if c]
1116
  self.log(user, '[CLEANUP] أُوقف FFmpeg (%s%s)' % (reason, '؛ رمز الخروج ' + ','.join(map(str, codes)) if codes else ''))
1117
 
1118
- def send(self, user, candidate, destination, stop, refresh=None):
1119
  """Push one rendition until it dies, the source stalls, or `stop` is set.
1120
 
1121
  الإشراف هنا على **البيانات** لا على العمليات: FFmpeg يبقى حيًا ومسرورًا وهو جالس
@@ -1176,7 +1218,7 @@ class Sender:
1176
  if self.telemetry_key==tele_key:self.telemetry_key=None
1177
  try:
1178
  sess['src_metered'] = not hls # HLS: FFmpeg يقرأ القائمة، فلا عدّاد مصدر لنا
1179
- args = self.command(destination, candidate)
1180
  telemetry_observe(tele_key,{'mode':self.active_mode or self.send_mode(),
1181
  'source_width':candidate.get('width'),
1182
  'source_height':candidate.get('height'),
@@ -1210,8 +1252,10 @@ class Sender:
1210
  with self.lock:
1211
  self.process = p
1212
  self.log(user, '[RELAY] بدء FFmpeg ← %s (%s؛ mode=%s)' % (candidate['label'],
1213
- 'إعلان Enhanced-RTMP: hvc1' if '-rtmp_enhanced_codecs' in ' '.join(args)
1214
  else 'FLV تقليدي: لا إعلان hvc1', self.active_mode or 'unknown')) # لا إعادة ترميز في هذا الوضع: انقطاع copy لا يترك عملية ورائها
 
 
1215
 
1216
  def read_errors():
1217
  # يصنّف أخطاء FFmpeg المختارة؛ لا يُسرّب stderr خامًا ولا روابط البث — الذيل
 
572
  return []
573
  return ['-rtmp_enhanced_codecs', 'hvc1'] if self.hevc_payload(candidate) else []
574
 
575
+ @staticmethod
576
+ def _dual_legs(destination,extra_destinations):
577
+ """وجهات النشر المتزامن الآمنة للمروحة: الأساسي ثم الاحتياطي، بلا مكرّر ولا
578
+ فواصل tee داخل أي عنوان. النشر المزدوج هو آلية يوتيوب الرسمية للوفرة:
579
+ بثّان متزامنان لنفس الحدث، فإذا مات الأساسي أكمل الاحتياطي أمام المشاهدين
580
+ بدل أن يبدأ الناشر من الصفر فيُرفض عند الفتح (187) لدقائق — حادثة
581
+ 2026-10-21 21:19Z: موت 152 ثم ست جلسات 187 وبطاقة noData لـ3د42ث."""
582
+ if str(os.getenv('YOUTUBE_DUAL_INGEST','1')).strip().lower() in ('0','off','false','no'):
583
+ return []
584
+ legs=[]
585
+ for url in [destination]+list(extra_destinations or []):
586
+ url=str(url or '')
587
+ if (url.startswith(('rtmp://','rtmps://')) and url not in legs
588
+ and '|' not in url and ']' not in url):
589
+ legs.append(url)
590
+ return legs if len(legs)>=2 else []
591
+
592
+ @staticmethod
593
+ def _tee_spec(legs,enhanced):
594
+ """مواصفة tee بساق FLV لكل وجهة: onfail=ignore يعني أن ساقًا ميتة (رفض فتح
595
+ أو قطع منتصف البث) تُسقَط وحدها وتكمل أختها — مُختبَر محليًا على ffmpeg
596
+ 7.0.2: قتل خادم RTMP بعد 4ث من بث 8ث فأكملت ساق الملف 8.02ث كاملة ورجع
597
+ الناشر 0. مهلة كتابة قصيرة لكل ساق (8ث بدل 20) حتى لا تُسمّم ساق محجوبة
598
+ أختها عبر خيط المروحة المشترك أكثر من ثوانٍ قبل أن تُسقَط."""
599
+ try:rw=str(int(os.getenv('YOUTUBE_DUAL_RW_TIMEOUT','8000000')))
600
+ except (TypeError,ValueError):rw='8000000'
601
+ opts='f=flv:onfail=ignore:flvflags=no_duration_filesize'
602
+ if enhanced:opts+=':rtmp_enhanced_codecs=hvc1'
603
+ opts+=':rw_timeout='+rw
604
+ return '|'.join('[%s]%s'%(opts,url) for url in legs)
605
+
606
+ def command(self,destination,candidate=None,extra_destinations=None):
607
  # إعلان Enhanced-RTMP يُقاس على الترميز المدفوع فعلًا؛ ملفات الاختبار المحلية لا بروتوكول لها.
608
  mode=self.send_mode()
609
  self.active_mode=mode
 
696
  '-recovery_wait_time',os.getenv('YOUTUBE_RECOVERY_WAIT','1'),
697
  '-queue_size',str(q),'-timeshift',str(ts)]
698
  else:
699
+ # v2.41: بلا timeshift ومع وجهة احتياطية معروفة، الخرج مروحة tee إلى
700
+ # الاستقبالين معًا (انظر _dual_legs) — الوفرة عند يوتيوب نفسه لا عند
701
+ # إعادة فتحنا. بلا احتياطي يبقى خرج N26 المباشر كما هو: تقدم out_time
702
+ # يعني تسليمًا فعليًا، بلا مخزن يكذب على الحارس.
703
+ legs=self._dual_legs(destination,extra_destinations)
704
+ if legs:
705
+ output=['-f','tee',self._tee_spec(legs,enhanced)]
706
+ else:
707
+ output=['-flvflags','no_duration_filesize','-f','flv']
708
  else:
709
  output=['-flvflags','no_duration_filesize','-f','flv']
710
  ta=self.input_args(candidate)
711
+ # مع المروحة تسكن الوجهات وإعلان hvc1 ومهلة الكتابة داخل مواصفة كل ساق؛
712
+ # إلحاق الوجهة المفردة أو الخيارات العامة بعد tee يُفسد الأمر كله.
713
+ tail=list(output) if (network and output[:1]==['-f'] and 'tee' in output) else \
714
+ [*enhanced,'-rw_timeout','20000000',*output,destination]
715
  return [self.ffmpeg,'-hide_banner','-loglevel',('debug' if network and self.trace_rtmp else os.getenv('YOUTUBE_FFMPEG_LOGLEVEL','error')),
716
  '-nostats','-progress','pipe:1',
717
  *ta['input'],'-map','0:v:0','-map','0:a:0?',
 
722
  *video,'-c:a','aac','-profile:a',os.getenv('YOUTUBE_AUDIO_PROFILE','aac_low'),
723
  '-b:a',os.getenv('YOUTUBE_AUDIO_BITRATE','128k'),'-ar','48000','-ac',os.getenv('YOUTUBE_AUDIO_CHANNELS','2'),
724
  '-af','aresample=async=1:first_pts=0','-avoid_negative_ts','make_zero',
725
+ *tail]
726
  # ------- مهل المصدر (كلها من البيئة؛ القيم الافتراضية مبرَّرة أدناه) -------
727
  @property
728
  def stall_limit(self):
 
1157
  codes = [c for c in codes if c]
1158
  self.log(user, '[CLEANUP] أُوقف FFmpeg (%s%s)' % (reason, '؛ رمز الخروج ' + ','.join(map(str, codes)) if codes else ''))
1159
 
1160
+ def send(self, user, candidate, destination, stop, refresh=None, extra_destinations=None):
1161
  """Push one rendition until it dies, the source stalls, or `stop` is set.
1162
 
1163
  الإشراف هنا على **البيانات** لا على العمليات: FFmpeg يبقى حيًا ومسرورًا وهو جالس
 
1218
  if self.telemetry_key==tele_key:self.telemetry_key=None
1219
  try:
1220
  sess['src_metered'] = not hls # HLS: FFmpeg يقرأ القائمة، فلا عدّاد مصدر لنا
1221
+ args = self.command(destination, candidate, extra_destinations=extra_destinations)
1222
  telemetry_observe(tele_key,{'mode':self.active_mode or self.send_mode(),
1223
  'source_width':candidate.get('width'),
1224
  'source_height':candidate.get('height'),
 
1252
  with self.lock:
1253
  self.process = p
1254
  self.log(user, '[RELAY] بدء FFmpeg ← %s (%s؛ mode=%s)' % (candidate['label'],
1255
+ 'إعلان Enhanced-RTMP: hvc1' if 'rtmp_enhanced_codecs' in ' '.join(args)
1256
  else 'FLV تقليدي: لا إعلان hvc1', self.active_mode or 'unknown')) # لا إعادة ترميز في هذا الوضع: انقطاع copy لا يترك عملية ورائها
1257
+ if extra_destinations and '-f' in args and 'tee' in args:
1258
+ self.log(user, '[RELAY] نشر مزدوج متزامن: استقبال يوتيوب الأساسي والاحتياطي معًا على نفس الحدث — موت ساق واحدة لا يوقف البث أمام المشاهدين')
1259
 
1260
  def read_errors():
1261
  # يصنّف أخطاء FFmpeg المختارة؛ لا يُسرّب stderr خامًا ولا روابط البث — الذيل
test_ingest_routes.py CHANGED
@@ -187,4 +187,60 @@ class PublishDisciplineTests(unittest.TestCase):
187
  self.mgr._note_early_exit('alice',s,11.0)
188
  self.assertNotIn('alice',self.mgr._publish_wait) # رجوع المصدر سريع دائمًا
189
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  if __name__=='__main__':unittest.main(argv=[sys.argv[0]],verbosity=2)
 
187
  self.mgr._note_early_exit('alice',s,11.0)
188
  self.assertNotIn('alice',self.mgr._publish_wait) # رجوع المصدر سريع دائمًا
189
 
190
+ class DualIngestTests(unittest.TestCase):
191
+ """v2.41: النشر المتزامن على الاستقبالين هو الوفرة الرسمية. موت ساق واحدة
192
+ (152 ثم عاصفة 187، 2026-10-21) لا يجوز أن يوقف البث ما دامت الأخرى حيّة."""
193
+ def setUp(self):
194
+ self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup)
195
+ self.mgr=ManagedYouTube('/bin/true',lambda u:None,lambda *a:None,lambda:['alice'],
196
+ pathlib.Path(self.tmp.name)/'journal.json')
197
+ self.mgr.journal.change(lambda d:d.update(channel={'id':'CHANNEL'},streams={'alice':{'id':'STREAM'}}))
198
+ self.primary='rtmps://a.rtmps.youtube.com/live2/test-ingest-only'
199
+ self.backup='rtmps://b.rtmps.youtube.com:443/live2/test-ingest-only?backup=1'
200
+ self.mgr._ingest_routes['alice']={'urls':[self.primary,self.backup],
201
+ 'channel_id':'CHANNEL','stream_id':'STREAM'}
202
+ self.mgr.destinations['alice']=self.primary
203
+
204
+ def test_destinations_are_simultaneous_pair_in_preference_order(self):
205
+ self.assertEqual(self.mgr.ingest_destinations('alice'),[self.primary,self.backup])
206
+ self.assertTrue(self.mgr.switch_ingest_route('alice'))
207
+ self.assertEqual(self.mgr.ingest_destinations('alice'),[self.backup,self.primary])
208
+
209
+ def test_destinations_fall_back_to_single_after_ownership_change(self):
210
+ self.mgr.journal.change(lambda d:d.update(channel={'id':'OTHER'}))
211
+ self.assertEqual(self.mgr.ingest_destinations('alice'),[self.primary])
212
+
213
+ def test_destinations_single_when_dual_disabled(self):
214
+ with patch.dict(os.environ,{'YOUTUBE_DUAL_INGEST':'off'}):
215
+ self.assertEqual(self.mgr.ingest_destinations('alice'),[self.primary])
216
+
217
+ def test_command_tee_fans_out_to_both_legs(self):
218
+ s=Sender('/bin/true',lambda *a:None);s.mode_override='copy'
219
+ args=s.command(self.primary,{'codec':'h264'},extra_destinations=[self.backup])
220
+ joined=' '.join(args)
221
+ self.assertIn('tee',args)
222
+ self.assertIn(self.primary,joined);self.assertIn(self.backup,joined)
223
+ self.assertEqual(joined.count('onfail=ignore'),2)
224
+ self.assertTrue(args[-1].endswith(self.backup)) # الوجهة داخل المواصفة لا بعدها
225
+ self.assertNotIn('rtmp_enhanced_codecs',joined) # h264: لا إعلان معزّز
226
+
227
+ def test_command_tee_advertises_hvc1_on_each_leg_for_hevc(self):
228
+ s=Sender('/bin/true',lambda *a:None);s.mode_override='copy'
229
+ args=s.command(self.primary,{'codec':'h265'},extra_destinations=[self.backup])
230
+ self.assertEqual(' '.join(args).count('rtmp_enhanced_codecs=hvc1'),2)
231
+
232
+ def test_command_single_destination_is_unchanged(self):
233
+ s=Sender('/bin/true',lambda *a:None);s.mode_override='copy'
234
+ args=s.command(self.primary,{'codec':'h264'})
235
+ self.assertEqual(args[-1],self.primary)
236
+ self.assertNotIn('tee',args)
237
+
238
+ def test_dual_legs_need_two_distinct_safe_urls(self):
239
+ self.assertEqual(Sender._dual_legs(self.primary,[]),[])
240
+ self.assertEqual(Sender._dual_legs(self.primary,[self.primary]),[])
241
+ self.assertEqual(Sender._dual_legs(self.primary,[self.backup]),
242
+ [self.primary,self.backup])
243
+ with patch.dict(os.environ,{'YOUTUBE_DUAL_INGEST':'0'}):
244
+ self.assertEqual(Sender._dual_legs(self.primary,[self.backup]),[])
245
+
246
  if __name__=='__main__':unittest.main(argv=[sys.argv[0]],verbosity=2)
test_relay.py CHANGED
@@ -428,7 +428,7 @@ last_i=pump.find("sess['last'] = time.monotonic()", pump.find('def _pump_source'
428
  check('empty FLV chunks do not refresh last', empty_i!=-1 and last_i!=-1 and empty_i<last_i, 'empty=%s last=%s'%(empty_i,last_i))
429
  td=pump.split('def _teardown')[1].split('def send')[0]
430
  check('teardown does not stop supervisor Event', 'stop.set()' not in td and "sess['force_stop']" in td)
431
- check('send keeps FFmpeg via refresh reattach', 'def _try_reattach' in pump and 'def send(self, user, candidate, destination, stop, refresh=None)' in pump)
432
  check('sender_loop pins codec family during refresh',
433
  "refresh=lambda: self._refresh_flv(user,getattr(sender,'current_candidate',None))" in mgr_src and
434
  'def _refresh_flv(self,user,current=None)' in mgr_src)
 
428
  check('empty FLV chunks do not refresh last', empty_i!=-1 and last_i!=-1 and empty_i<last_i, 'empty=%s last=%s'%(empty_i,last_i))
429
  td=pump.split('def _teardown')[1].split('def send')[0]
430
  check('teardown does not stop supervisor Event', 'stop.set()' not in td and "sess['force_stop']" in td)
431
+ check('send keeps FFmpeg via refresh reattach', 'def _try_reattach' in pump and 'def send(self, user, candidate, destination, stop, refresh=None, extra_destinations=None)' in pump)
432
  check('sender_loop pins codec family during refresh',
433
  "refresh=lambda: self._refresh_flv(user,getattr(sender,'current_candidate',None))" in mgr_src and
434
  'def _refresh_flv(self,user,current=None)' in mgr_src)
youtube_manager.py CHANGED
@@ -531,6 +531,27 @@ class ManagedYouTube:
531
  # not after the query string, and neither endpoint nor key is persisted.
532
  return urlunparse((p.scheme,p.netloc,p.path.rstrip('/')+'/'+quote(str(key),safe=''),'',p.query,''))
533
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
534
  def switch_ingest_route(self,user):
535
  routes=self._ingest_routes.get(user) or {}
536
  d=self.journal.read()
@@ -1234,12 +1255,15 @@ class ManagedYouTube:
1234
  # الوجهة تُقرأ في كل دورة: لو تغيّر مفتاح الاستقبال (إعادة استخدام المصدر أو
1235
  # إعادة ربط للحدث) بقي هذا الخيط يطرق الباب القديم بمفتاح قديم إلى الأبد.
1236
  destination = self.destinations.get(user) or destination
 
 
1237
  try:
1238
  self.log(user,'[RELAY] @%s starting (%s)'%(user,self._source_brief(candidate)))
1239
  sender.telemetry_context={'event_key':key,'room_id':room_id,
1240
  'broadcast_id':(self.journal.read()['events'].get(key) or {}).get('broadcast_id','')}
1241
  if sender.send(user,candidate,destination,stop,
1242
- refresh=lambda: self._refresh_flv(user,getattr(sender,'current_candidate',None))):
 
1243
  sent=True
1244
  self._rendition_pin[user]=self._source_pin(getattr(sender,'current_candidate',None) or candidate)
1245
  sender.lock_gold(candidate)
 
531
  # not after the query string, and neither endpoint nor key is persisted.
532
  return urlunparse((p.scheme,p.netloc,p.path.rstrip('/')+'/'+quote(str(key),safe=''),'',p.query,''))
533
 
534
+ def ingest_destinations(self,user):
535
+ """وجهات النشر المتزامن [المفضّلة الآن، ثم الباقي] من مسارات YouTube API
536
+ المحفوظة لهذا (قناة، مصدر) حصرًا — نفس حارس الملكية في switch_ingest_route
537
+ حتى لا تُنشر وجهة قناة/مصدر سابق على حدث جديد. النشر على الاستقبالين معًا
538
+ (لا التنقّل بينهما بعد الموت) هو وفرة يوتيوب الرسمية: الاحتياطي يصل قبل
539
+ الحاجة إليه، فموت الأساسي لا يمرّ بالمشاهدين. وجهة البيئة (env) تبقى
540
+ مفردة: لا زوج احتياطيًا موثّقًا لها. YOUTUBE_DUAL_INGEST=off يرجع للمفرد."""
541
+ single=self.destinations.get(user)
542
+ if str(os.getenv('YOUTUBE_DUAL_INGEST','1')).strip().lower() in ('0','off','false','no'):
543
+ return [single] if single else []
544
+ routes=self._ingest_routes.get(user) or {}
545
+ urls=list(routes.get('urls') or [])
546
+ d=self.journal.read()
547
+ if (len(urls)>=2 and routes.get('channel_id')==(d.get('channel') or {}).get('id')
548
+ and routes.get('stream_id')==(d.get('streams',{}).get(user) or {}).get('id')):
549
+ pref=int(self._ingest_preferred.get(user,0) or 0)
550
+ if 0<=pref<len(urls):
551
+ return [urls[pref]]+[u for i,u in enumerate(urls) if i!=pref]
552
+ return urls
553
+ return [single] if single else []
554
+
555
  def switch_ingest_route(self,user):
556
  routes=self._ingest_routes.get(user) or {}
557
  d=self.journal.read()
 
1255
  # الوجهة تُقرأ في كل دورة: لو تغيّر مفتاح الاستقبال (إعادة استخدام المصدر أو
1256
  # إعادة ربط للحدث) بقي هذا الخيط يطرق الباب القديم بمفتاح قديم إلى الأبد.
1257
  destination = self.destinations.get(user) or destination
1258
+ dests = self.ingest_destinations(user) or ([destination] if destination else [])
1259
+ if dests: destination = dests[0]
1260
  try:
1261
  self.log(user,'[RELAY] @%s starting (%s)'%(user,self._source_brief(candidate)))
1262
  sender.telemetry_context={'event_key':key,'room_id':room_id,
1263
  'broadcast_id':(self.journal.read()['events'].get(key) or {}).get('broadcast_id','')}
1264
  if sender.send(user,candidate,destination,stop,
1265
+ refresh=lambda: self._refresh_flv(user,getattr(sender,'current_candidate',None)),
1266
+ extra_destinations=dests[1:]):
1267
  sent=True
1268
  self._rendition_pin[user]=self._source_pin(getattr(sender,'current_candidate',None) or candidate)
1269
  sender.lock_gold(candidate)