Spaces:
Running
Running
v2.41 dual simultaneous YouTube ingest (primary+backup tee fan-out)
Browse files- ingest.py +1 -1
- relay.py +51 -7
- test_ingest_routes.py +56 -0
- test_relay.py +1 -1
- 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.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 669 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
*
|
| 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 '
|
| 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)
|