Reoo88 commited on
Commit
9d0318c
·
verified ·
1 Parent(s): ce0ef24

Persistent room format lock + fill/swap-aware write deadline (v2.33-format-lock-fillguard)

Browse files
Files changed (4) hide show
  1. ingest.py +1 -1
  2. relay.py +13 -1
  3. test_smart_mode.py +70 -0
  4. 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.32-gap-filler'
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['last'] + stall_limit)
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
  # جوع مصدر لا رفض وجهة: التأجيل هنا يطيل أمد الصمت الذي يراه المشاهدون. نُسرّع