Spaces:
Running
Running
Persistent room format lock + fill/swap-aware write deadline (v2.33-format-lock-fillguard)
Browse files- ingest.py +1 -1
- relay.py +13 -1
- test_smart_mode.py +70 -0
- youtube_manager.py +38 -0
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.33-format-lock-fillguard'
|
| 10 |
|
| 11 |
|
| 12 |
def env_rtmp_destination():
|
relay.py
CHANGED
|
@@ -701,6 +701,18 @@ class Sender:
|
|
| 701 |
raw = os.getenv('YOUTUBE_SOURCE_STALL_SECONDS', '12')
|
| 702 |
return max(0.0, float(raw))
|
| 703 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 704 |
def _stream_tuning(self):
|
| 705 |
"""ما يعادله في هذه الشجرة لخيارات Streamlink (المشروع لا يستعمل Streamlink أصلًا):
|
| 706 |
|
|
@@ -835,7 +847,7 @@ class Sender:
|
|
| 835 |
try:
|
| 836 |
with sess['stdin_lock']:
|
| 837 |
if stall_limit:
|
| 838 |
-
self._write_deadline(p.stdin, buf, sess
|
| 839 |
else:
|
| 840 |
view = memoryview(buf)
|
| 841 |
while view and not stop.is_set() and not sess['force_stop']:
|
|
|
|
| 701 |
raw = os.getenv('YOUTUBE_SOURCE_STALL_SECONDS', '12')
|
| 702 |
return max(0.0, float(raw))
|
| 703 |
|
| 704 |
+
@staticmethod
|
| 705 |
+
def _deadline_base(sess):
|
| 706 |
+
"""مرجع مهلة كتابة stdin: آخر بايت مصدر عادةً. أثناء حشو فجوة أو تبديل مصدر
|
| 707 |
+
فالصمت مخطّط له والضخّ قد يكون من خيط الحشو — المرجع يصير آخر تقدّم فعلي
|
| 708 |
+
من FFmpeg (ff_last)، فلا يُقتل خرج يعمل بتهمة «عالق» بينما المُرمِّز يطحن
|
| 709 |
+
إطارات مجمّدة أو يخيط مصدرًا جديدًا (مقيس: talolil 00:21:50 و00:30:49).
|
| 710 |
+
خرج محجوب حقًا يجمّد التقدّم أيضًا، فتمضي المهلة ويموت بنظافة كالعادة."""
|
| 711 |
+
base=float(sess.get('last') or 0)
|
| 712 |
+
if sess.get('filling') or sess.get('swapping'):
|
| 713 |
+
base=max(base,float(sess.get('ff_last') or 0))
|
| 714 |
+
return base
|
| 715 |
+
|
| 716 |
def _stream_tuning(self):
|
| 717 |
"""ما يعادله في هذه الشجرة لخيارات Streamlink (المشروع لا يستعمل Streamlink أصلًا):
|
| 718 |
|
|
|
|
| 847 |
try:
|
| 848 |
with sess['stdin_lock']:
|
| 849 |
if stall_limit:
|
| 850 |
+
self._write_deadline(p.stdin, buf, self._deadline_base(sess) + stall_limit)
|
| 851 |
else:
|
| 852 |
view = memoryview(buf)
|
| 853 |
while view and not stop.is_set() and not sess['force_stop']:
|
test_smart_mode.py
CHANGED
|
@@ -209,5 +209,75 @@ class FormatLockTests(unittest.TestCase):
|
|
| 209 |
self.assertEqual(sender.send_mode(), 'copy')
|
| 210 |
|
| 211 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
if __name__ == '__main__':
|
| 213 |
unittest.main(argv=[sys.argv[0]], verbosity=2)
|
|
|
|
| 209 |
self.assertEqual(sender.send_mode(), 'copy')
|
| 210 |
|
| 211 |
|
| 212 |
+
class _ExitSender:
|
| 213 |
+
"""أصغر مرسل يراه _note_early_exit عند نهاية جلسة."""
|
| 214 |
+
def __init__(self, unstable=False, mode='matchgop', out=0.0):
|
| 215 |
+
self.format_unstable = unstable
|
| 216 |
+
self.last_exit_code = 187
|
| 217 |
+
self.last_failure_side = 'output'
|
| 218 |
+
self.last_source_gap = 0.0
|
| 219 |
+
self._snap = {'output_seconds': out, 'rtmp_phase': 'accepted',
|
| 220 |
+
'source_bytes': 4096, 'send_mode': mode}
|
| 221 |
+
def snapshot(self): return dict(self._snap)
|
| 222 |
+
def send_mode(self): return self._snap['send_mode']
|
| 223 |
+
def request_sticky(self, *a, **k): return False
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
class ManagerFormatLockTests(unittest.TestCase):
|
| 227 |
+
"""قفل التطبيع محفوظ في السجلّ على مستوى (حساب، غرفة): إعادة بناء المرسل
|
| 228 |
+
لا تمحوه، وجلسة COPY مستقرة على نفس الغرفة وحدها تفكّه."""
|
| 229 |
+
|
| 230 |
+
def make_manager(self):
|
| 231 |
+
tmp = tempfile.TemporaryDirectory(prefix='fmt-lock-')
|
| 232 |
+
self.addCleanup(tmp.cleanup)
|
| 233 |
+
manager = ManagedYouTube('/bin/true', lambda u: None, lambda *a: None,
|
| 234 |
+
lambda: ['alice'], pathlib.Path(tmp.name) / 'journal.json')
|
| 235 |
+
manager.journal.change(lambda d: d['events'].update(
|
| 236 |
+
{'alice:R1': {'username': 'alice', 'room_id': 'R1'}}))
|
| 237 |
+
return manager
|
| 238 |
+
|
| 239 |
+
def test_instability_is_journaled_for_the_room(self):
|
| 240 |
+
manager = self.make_manager()
|
| 241 |
+
manager._note_early_exit('alice', _ExitSender(unstable=True), 11.0)
|
| 242 |
+
self.assertEqual(manager._format_lock_room('alice'), 'R1')
|
| 243 |
+
|
| 244 |
+
def test_fresh_sender_is_armed_from_the_journal(self):
|
| 245 |
+
manager = self.make_manager()
|
| 246 |
+
manager._note_early_exit('alice', _ExitSender(unstable=True), 11.0)
|
| 247 |
+
sender = Sender('/bin/true', lambda *a: None)
|
| 248 |
+
self.assertFalse(sender.format_unstable)
|
| 249 |
+
self.assertTrue(manager._arm_format_lock('alice', sender, 'R1'))
|
| 250 |
+
self.assertTrue(sender.format_unstable)
|
| 251 |
+
|
| 252 |
+
def test_lock_does_not_leak_into_a_new_room(self):
|
| 253 |
+
manager = self.make_manager()
|
| 254 |
+
manager._note_early_exit('alice', _ExitSender(unstable=True), 11.0)
|
| 255 |
+
sender = Sender('/bin/true', lambda *a: None)
|
| 256 |
+
self.assertFalse(manager._arm_format_lock('alice', sender, 'R2'))
|
| 257 |
+
self.assertFalse(sender.format_unstable)
|
| 258 |
+
|
| 259 |
+
def test_verified_copy_session_releases_the_lock(self):
|
| 260 |
+
manager = self.make_manager()
|
| 261 |
+
manager._note_early_exit('alice', _ExitSender(unstable=True), 11.0)
|
| 262 |
+
self.assertEqual(manager._format_lock_room('alice'), 'R1')
|
| 263 |
+
manager._note_early_exit('alice', _ExitSender(unstable=False, mode='copy', out=60.0), 65.0)
|
| 264 |
+
self.assertEqual(manager._format_lock_room('alice'), '')
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
class WriteDeadlineTests(unittest.TestCase):
|
| 268 |
+
"""مهلة كتابة stdin أثناء حشو/تبديل تُقاس على تقدّم FFmpeg لا على صمت المصدر."""
|
| 269 |
+
|
| 270 |
+
def test_normal_base_is_last_source_byte(self):
|
| 271 |
+
self.assertEqual(Sender._deadline_base({'last': 100.0, 'ff_last': 150.0}), 100.0)
|
| 272 |
+
|
| 273 |
+
def test_filling_base_follows_ffmpeg_progress(self):
|
| 274 |
+
self.assertEqual(Sender._deadline_base(
|
| 275 |
+
{'last': 100.0, 'ff_last': 150.0, 'filling': True}), 150.0)
|
| 276 |
+
|
| 277 |
+
def test_swapping_base_never_moves_backwards(self):
|
| 278 |
+
self.assertEqual(Sender._deadline_base(
|
| 279 |
+
{'last': 200.0, 'ff_last': 150.0, 'swapping': True}), 200.0)
|
| 280 |
+
|
| 281 |
+
|
| 282 |
if __name__ == '__main__':
|
| 283 |
unittest.main(argv=[sys.argv[0]], verbosity=2)
|
youtube_manager.py
CHANGED
|
@@ -1020,6 +1020,7 @@ class ManagedYouTube:
|
|
| 1020 |
sender.on_sticky=self._remember_sender_mode
|
| 1021 |
sender.on_sticky_pending=self._remember_pending_sender_mode
|
| 1022 |
sender.on_sticky_clear=self._clear_sender_mode
|
|
|
|
| 1023 |
forced=self.forced_mode(u)
|
| 1024 |
saved_mode=str(ev.get('sender_mode') or self._sender_sticky.get(u) or '')
|
| 1025 |
pending=self._sender_pending.get(u)
|
|
@@ -1321,6 +1322,42 @@ class ManagedYouTube:
|
|
| 1321 |
self.log(user, '[WATCHDOG] الاستقبال يقبل التدفق؛ مُسح تأجيل العقاب وأثره من اللوحة (النوافذ: %d)' % cleared)
|
| 1322 |
return cleared
|
| 1323 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1324 |
def _note_early_exit(self, user, sender, lasted):
|
| 1325 |
"""وتيرة إعادة الاتصال عند رفض الوجهة: يوتيوب يقفل الاتصال بعد ثوانٍ من كل محاولة
|
| 1326 |
(رمز 187 أو قطعه من الخادم) — هذا رفض عند حافة الاستقبال لا صمت مصدر، والنقر كل 15ث
|
|
@@ -1330,6 +1367,7 @@ class ManagedYouTube:
|
|
| 1330 |
output_side = getattr(sender, 'last_failure_side', '') == 'output'
|
| 1331 |
if code is None and not output_side:
|
| 1332 |
return # لم تخرج العملية وحدَها: لا حكم على الوجهة
|
|
|
|
| 1333 |
gap = float(getattr(sender, 'last_source_gap', 0.0) or 0.0)
|
| 1334 |
if gap >= float(os.getenv('YOUTUBE_SOURCE_GAP_SUSPECT_SECONDS', '8')) and not output_side:
|
| 1335 |
# جوع مصدر لا رفض وجهة: التأجيل هنا يطيل أمد الصمت الذي يراه المشاهدون. نُسرّع
|
|
|
|
| 1020 |
sender.on_sticky=self._remember_sender_mode
|
| 1021 |
sender.on_sticky_pending=self._remember_pending_sender_mode
|
| 1022 |
sender.on_sticky_clear=self._clear_sender_mode
|
| 1023 |
+
self._arm_format_lock(u,sender,ev.get('room_id'))
|
| 1024 |
forced=self.forced_mode(u)
|
| 1025 |
saved_mode=str(ev.get('sender_mode') or self._sender_sticky.get(u) or '')
|
| 1026 |
pending=self._sender_pending.get(u)
|
|
|
|
| 1322 |
self.log(user, '[WATCHDOG] الاستقبال يقبل التدفق؛ مُسح تأجيل العقاب وأثره من اللوحة (النوافذ: %d)' % cleared)
|
| 1323 |
return cleared
|
| 1324 |
|
| 1325 |
+
def _format_lock_room(self,user):
|
| 1326 |
+
try:return str((self.journal.read().get('streams',{}).get(user) or {}).get('format_lock_room') or '')
|
| 1327 |
+
except Exception:return ''
|
| 1328 |
+
|
| 1329 |
+
def _note_format_instability(self,user,sender):
|
| 1330 |
+
"""قفل تطبيع محفوظ على مستوى (حساب، غرفة): الجسر وثّق GOP/DTS مضطربًا على
|
| 1331 |
+
هذا المرسل، لكن start_sender يبني مرسلًا جديدًا عند كل إعادة إشراف فيُنسى
|
| 1332 |
+
قفل مثيله (مقيس: 18_x200 رجع copy بعد إعادة بناء فماتت جلسته). الحفظ في
|
| 1333 |
+
سجلّ القنوات يجعل أول جلسة بعد أي إعادة بناء تبدأ مطبّعة. الفكّ مشروط:
|
| 1334 |
+
جلسة COPY نظيفة ≥25 ث على نفس الغرفة (فحص حاكم البطء عادة) تعني أن المصدر
|
| 1335 |
+
تصالح. غرفة جديدة = صفحة بيضاء دائمًا."""
|
| 1336 |
+
try:unstable=bool(getattr(sender,'format_unstable',False))
|
| 1337 |
+
except Exception:unstable=False
|
| 1338 |
+
room=''
|
| 1339 |
+
for ev in self.journal.read().get('events',{}).values():
|
| 1340 |
+
if ev.get('username')==user and ev.get('room_id'):room=str(ev['room_id'])
|
| 1341 |
+
if not room:return
|
| 1342 |
+
if unstable:
|
| 1343 |
+
if self._format_lock_room(user)!=room:
|
| 1344 |
+
self.streams_change(user,format_lock_room=room)
|
| 1345 |
+
self.log(user,'[FORMAT] وُثّق اضطراب صيغة على هذه الغرفة؛ حُفظ قفل التطبيع في السجلّ فلا يُنسى بإعادة بناء الناشر')
|
| 1346 |
+
return
|
| 1347 |
+
if self._format_lock_room(user)==room:
|
| 1348 |
+
try:snap=sender.snapshot() or {}
|
| 1349 |
+
except Exception:snap={}
|
| 1350 |
+
if str(snap.get('send_mode') or '')=='copy' and float(snap.get('output_seconds') or 0)>=25:
|
| 1351 |
+
self.streams_change(user,format_lock_room='')
|
| 1352 |
+
self.log(user,'[FORMAT] جلسة COPY مستقرة ≥25 ث على نفس الغرفة؛ فُكّ قفل التطبيع المحفوظ')
|
| 1353 |
+
|
| 1354 |
+
def _arm_format_lock(self,user,sender,room):
|
| 1355 |
+
if room and self._format_lock_room(user)==str(room):
|
| 1356 |
+
sender.format_unstable=True
|
| 1357 |
+
self.log(user,'[FORMAT] قفل تطبيع محفوظ لهذه الغرفة من اضطراب سابق؛ نبدأ مطبّعًا بدل إعادة اكتشافه بموت جلسات')
|
| 1358 |
+
return True
|
| 1359 |
+
return False
|
| 1360 |
+
|
| 1361 |
def _note_early_exit(self, user, sender, lasted):
|
| 1362 |
"""وتيرة إعادة الاتصال عند رفض الوجهة: يوتيوب يقفل الاتصال بعد ثوانٍ من كل محاولة
|
| 1363 |
(رمز 187 أو قطعه من الخادم) — هذا رفض عند حافة الاستقبال لا صمت مصدر، والنقر كل 15ث
|
|
|
|
| 1367 |
output_side = getattr(sender, 'last_failure_side', '') == 'output'
|
| 1368 |
if code is None and not output_side:
|
| 1369 |
return # لم تخرج العملية وحدَها: لا حكم على الوجهة
|
| 1370 |
+
self._note_format_instability(user, sender)
|
| 1371 |
gap = float(getattr(sender, 'last_source_gap', 0.0) or 0.0)
|
| 1372 |
if gap >= float(os.getenv('YOUTUBE_SOURCE_GAP_SUSPECT_SECONDS', '8')) and not output_side:
|
| 1373 |
# جوع مصدر لا رفض وجهة: التأجيل هنا يطيل أمد الصمت الذي يراه المشاهدون. نُسرّع
|