Arena maintenance commited on
Commit
dc5fd17
·
1 Parent(s): 1de09cd

Persist live stream transition diagnostics

Browse files
Files changed (12) hide show
  1. AUDIT_AR.md +22 -4
  2. Dockerfile +3 -2
  3. README.md +4 -2
  4. app.py +6 -0
  5. flv_bridge.py +67 -8
  6. ingest.py +1 -1
  7. relay.py +14 -2
  8. stream_monitor.py +220 -0
  9. test_flv_bridge.py +43 -3
  10. test_regressions.py +3 -0
  11. test_relay.py +3 -1
  12. youtube_manager.py +4 -1
AUDIT_AR.md CHANGED
@@ -1,6 +1,6 @@
1
  # تقرير تدقيق وإصلاح Space `Reoo88/On`
2
 
3
- **التاريخ:** 2026-10-07 — **النسخة المنشورة قبل هذا التغيير:** `0a92b5b` — **النسخة الجديدة المرشحة:** `relay-v2.19-config-refresh`
4
 
5
  > هذا تقرير تقني وتشغيلي، وليس وعدًا بمنع كل عطل مستقبلي. TikTok وYouTube وHugging Face خدمات خارجية تتغير دون تحكم المشروع؛ الهدف هو منع الأعطال المعروفة، كشف الأعطال الجديدة بسرعة، والتعافي الآمن دون إنشاء أحداث مكررة.
6
 
@@ -185,6 +185,22 @@ Enhanced RTMP v2 يعرّف multitrack، لكن ذلك لا يجعل track وا
185
 
186
  أي أن فيديو H.264 ينسخ، وفيديو H.265 ينسخ مع إعلان `hvc1`، ولا يوجد فك/scale/x264 في هذا الإصلاح. يبقى AAC normalization منفصلًا ولا يغيّر إطارات الفيديو. مسار x264 التاريخي ما زال في المصدر كخيار يدوي فقط؛ رفع `YOUTUBE_LIGHT_MAX` هو opt-in صريح.
187
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  #### المراجع التي حُكم بها
189
 
190
  - Enhanced RTMP v2 الرسمي: <https://veovera.org/docs/enhanced/enhanced-rtmp-v2.pdf>
@@ -208,13 +224,13 @@ YouTube يسرد H.265 ضمن codecs المقبولة حاليًا لـRTMP/RTMP
208
 
209
  ## 5. اختبارات حقيقية ومحاكاة واقعية
210
 
211
- نفذ `verify.sh` بعد إصلاح config passthrough، والنتيجة **314/314** فحصًا ناجحًا:
212
 
213
  - فحص بنيوي واحد يؤكد غياب التسجيل وواجهة تنزيل الفيديو.
214
  - 25 فحص تزامن وعزل حسابات.
215
- - 28 regression للدخول والخصوصية وحدود الكوكي وفصل عطل RTMPS عن جوع المصدر.
216
  - 83 فحص relay/API/UI، ومنها غياب أي runtime caller لـ`FormatChanged` وافتراضات Docker الخاصة بالنسخ.
217
- - 23 فحصًا جديدًا خاصًا بـFLVBridge: AVC/HEVC، CTS/DTS، reconnect، multi-SPS/PPS، transition مرصود، وتمرير FFmpeg الحقيقي للسجلين ولـaccess units بالبايت.
218
  - 40 فحص completion، منها FFmpeg حقي��ي ومقارنة مهلات الصمت 12/30/45 ثانية.
219
  - 38 فحص watchdog بشبكة HTTP محلية وFFmpeg حقيقي، ومنها أن HEVC يستخدم `-c:v copy` ولا يدخل `libx264`.
220
  - 76 فحص lifecycle، منها source stall → cleanup → re-observe → session جديدة، استئناف حدث YouTube نفسه، حدود التزامن، 403، offline، HLS fallback، وعدم تراكم العمليات.
@@ -242,6 +258,8 @@ YouTube يسرد H.265 ضمن codecs المقبولة حاليًا لـRTMP/RTMP
242
  | `YOUTUBE_FORCE_TRANSCODE_USERS` | احذف المتغير | لا يُجبر أحمد أو Strong على `matchgop` |
243
  | `YOUTUBE_ENHANCED_RTMP` | `auto` | يعلن `hvc1` فقط عندما يكون payload المنسوخ HEVC، ولا يعلنه كذبًا على H.264 |
244
  | `YOUTUBE_VIDEO_MODE` | `original` | يبقي H.264 وH.265 ضمن المرشحين؛ يفضل H.264 مفيدًا عند وجوده ويقبل HEVC عند غيابه |
 
 
245
  | `YOUTUBE_DAILY_BUDGET` | اترك القيمة الحالية المدروسة | منع استنزاف API |
246
 
247
  في سقفي الناشرين/الأحداث تعني `0` بلا سقف، فلا تضعها بدل `4`. أما `YOUTUBE_LIGHT_MAX=0` فمعناه المقصود **صفر عمليات x264**. Variables في الـSpace تتقدم على Dockerfile، لذا يجب حذف القيم القديمة `2` وقائمة الحسابات المجبرة عند النشر.
 
1
  # تقرير تدقيق وإصلاح Space `Reoo88/On`
2
 
3
+ **التاريخ:** 2026-10-07 — **النسخة المنشورة قبل هذا التغيير:** `fa14eaf` — **النسخة الجديدة المرشحة:** `relay-v2.20-stream-monitor`
4
 
5
  > هذا تقرير تقني وتشغيلي، وليس وعدًا بمنع كل عطل مستقبلي. TikTok وYouTube وHugging Face خدمات خارجية تتغير دون تحكم المشروع؛ الهدف هو منع الأعطال المعروفة، كشف الأعطال الجديدة بسرعة، والتعافي الآمن دون إنشاء أحداث مكررة.
6
 
 
185
 
186
  أي أن فيديو H.264 ينسخ، وفيديو H.265 ينسخ مع إعلان `hvc1`، ولا يوجد فك/scale/x264 في هذا الإصلاح. يبقى AAC normalization منفصلًا ولا يغيّر إطارات الفيديو. مسار x264 التاريخي ما زال في المصدر كخيار يدوي فقط؛ رفع `YOUTUBE_LIGHT_MAX` هو opt-in صريح.
187
 
188
+ #### سجل أحداث البث الدائم
189
+
190
+ أضيف `stream_monitor.py` ويكتب مصفوفة JSON محدودة افتراضيًا إلى `logs/stream_events.json` تحت `DATA_DIR`/Storage Bucket؛ يضيف كل حدث بكلفة ثابتة تقريبًا ويضغط الملف دوريًا عبر atomic replace بدل إعادة كتابة الملف كاملًا مع كل health poll. كل سجل يحوي `timestamp` بتوقيت UTC و`sequence` و`broadcaster` و`event` و`details`. لا تُكتب فيديوهات أو access units أو URLs أو stream keys أو cookies/tokens؛ المرشح يحذف هذه المفاتيح قبل الكتابة، والملف `0600` ويحتفظ بآخر 10,000 حدث فقط.
191
+
192
+ الأحداث المتصلة فعليًا بمصادرها:
193
+
194
+ - `CODEC_CHANGE`: من مقارنة FourCC في SequenceStart داخل `FLVBridge`.
195
+ - `RESOLUTION_CHANGE`: من parsing بايتي لـSPS داخل avcC/hvcC، من دون فك إطار.
196
+ - `BITRATE_CHANGE`: نافذة أربع ثوانٍ من أحجام video tags وDTS، بعتبة نسبة 35% وفارق 50 kbit/s لتجنب ضجيج VBR العادي.
197
+ - `KEYFRAME_CHANGE`: من keyframe flag وفرق DTS بين الإطارات المفتاحية، مع rate limit.
198
+ - `RECONNECT`: يميز `scope=tiktok_http` مع `outbound_rtmp_reused=true` عن `scope=youtube_rtmp` عند فتح ناشر خرج ثانٍ فعليًا.
199
+ - `CONNECTION_ERROR`: من فشل الفتح أو حارس source/output أو خروج FFmpeg على جهة YouTube.
200
+ - `YOUTUBE_HEALTH`: من `status.healthStatus` وقائمة configuration issues كل 20 ثانية (الحد الأدنى 15 ثانية).
201
+
202
+ يمكن للمشغل قراءة السجل من endpoint محمي بالمصادقة: `GET /api/stream-events?limit=200&broadcaster=user`. هذا السجل هو دليل اختبار PK والخلفية والإشعارات؛ لكنه لا يستبدل مشاهدة عميل YouTube فعلية لأن العطل الأصلي كان player-side بينما VOD سليم.
203
+
204
  #### المراجع التي حُكم بها
205
 
206
  - Enhanced RTMP v2 الرسمي: <https://veovera.org/docs/enhanced/enhanced-rtmp-v2.pdf>
 
224
 
225
  ## 5. اختبارات حقيقية ومحاكاة واقعية
226
 
227
+ نفذ `verify.sh` بعد إصلاح config passthrough وإضافة المراقبة، والنتيجة **325/325** فحصًا ناجحًا:
228
 
229
  - فحص بنيوي واحد يؤكد غياب التسجيل وواجهة تنزيل الفيديو.
230
  - 25 فحص تزامن وعزل حسابات.
231
+ - 29 regression للدخول والخصوصية وحدود الكوكي وفصل عطل RTMPS عن جوع المصدر، ومنها API سجل الأحداث المحمي.
232
  - 83 فحص relay/API/UI، ومنها غياب أي runtime caller لـ`FormatChanged` وافتراضات Docker الخاصة بالنسخ.
233
+ - 33 فحصًا خاصًا بـFLVBridge/StreamMonitor: AVC/HEVC، CTS/DTS، reconnect، multi-SPS/PPS، parsing دقة SPS، codec/resolution/bitrate/keyframe events، JSON وصلاحية `0600` وتنقية الأسرار، وتمرير FFmpeg الحقيقي للسجلين ولـaccess units بالبايت.
234
  - 40 فحص completion، منها FFmpeg حقي��ي ومقارنة مهلات الصمت 12/30/45 ثانية.
235
  - 38 فحص watchdog بشبكة HTTP محلية وFFmpeg حقيقي، ومنها أن HEVC يستخدم `-c:v copy` ولا يدخل `libx264`.
236
  - 76 فحص lifecycle، منها source stall → cleanup → re-observe → session جديدة، استئناف حدث YouTube نفسه، حدود التزامن، 403، offline، HLS fallback، وعدم تراكم العمليات.
 
258
  | `YOUTUBE_FORCE_TRANSCODE_USERS` | احذف المتغير | لا يُجبر أحمد أو Strong على `matchgop` |
259
  | `YOUTUBE_ENHANCED_RTMP` | `auto` | يعلن `hvc1` فقط عندما يكون payload المنسوخ HEVC، ولا يعلنه كذبًا على H.264 |
260
  | `YOUTUBE_VIDEO_MODE` | `original` | يبقي H.264 وH.265 ضمن المرشحين؛ يفضل H.264 مفيدًا عند وجوده ويقبل HEVC عند غيابه |
261
+ | `YOUTUBE_HEALTH_INTERVAL` | `20` | يسجل healthStatus أثناء سيناريو الاختبار؛ راقب quota عند 3–5 أحداث متزامنة |
262
+ | `STREAM_EVENT_MAX` | `10000` | يمنع نمو `stream_events.json` بلا حد |
263
  | `YOUTUBE_DAILY_BUDGET` | اترك القيمة الحالية المدروسة | منع استنزاف API |
264
 
265
  في سقفي الناشرين/الأحداث تعني `0` بلا سقف، فلا تضعها بدل `4`. أما `YOUTUBE_LIGHT_MAX=0` فمعناه المقصود **صفر عمليات x264**. Variables في الـSpace تتقدم على Dockerfile، لذا يجب حذف القيم القديمة `2` وقائمة الحسابات المجبرة عند النشر.
Dockerfile CHANGED
@@ -5,7 +5,7 @@ RUN ffmpeg -hide_banner -h protocol=rtmps 2>&1 | grep -q rtmp_enhanced_codecs
5
  WORKDIR /app
6
  COPY requirements.txt .
7
  RUN pip install --no-cache-dir -U -r requirements.txt
8
- COPY app.py relay.py youtube_api.py youtube_manager.py flv_bridge.py tiktok_auth.py ingest.py index.html ./
9
  COPY verify.sh verify_tokens.py probe_live.py test_watchdog.py test_lifecycle.py test_relay.py test_concurrency.py test_flv_bridge.py ./
10
 
11
  # TikTok Live Monitor → Direct Relay → YouTube. No local video files.
@@ -35,7 +35,8 @@ ENV TZ=Asia/Baghdad \
35
  YOUTUBE_COPY_RECOVERY_SECONDS=60 \
36
  YOUTUBE_X264_PRESET=ultrafast \
37
  YOUTUBE_FIFO_TIMESHIFT=0 \
38
- YOUTUBE_HEALTH_INTERVAL=60 \
 
39
  YOUTUBE_OBS_HOLD_SECONDS=900 \
40
  YOUTUBE_SOURCE_RETRY=15 \
41
  YOUTUBE_FORCE_GAP=5 \
 
5
  WORKDIR /app
6
  COPY requirements.txt .
7
  RUN pip install --no-cache-dir -U -r requirements.txt
8
+ COPY app.py relay.py youtube_api.py youtube_manager.py flv_bridge.py stream_monitor.py tiktok_auth.py ingest.py index.html ./
9
  COPY verify.sh verify_tokens.py probe_live.py test_watchdog.py test_lifecycle.py test_relay.py test_concurrency.py test_flv_bridge.py ./
10
 
11
  # TikTok Live Monitor → Direct Relay → YouTube. No local video files.
 
35
  YOUTUBE_COPY_RECOVERY_SECONDS=60 \
36
  YOUTUBE_X264_PRESET=ultrafast \
37
  YOUTUBE_FIFO_TIMESHIFT=0 \
38
+ YOUTUBE_HEALTH_INTERVAL=20 \
39
+ STREAM_EVENT_MAX=10000 \
40
  YOUTUBE_OBS_HOLD_SECONDS=900 \
41
  YOUTUBE_SOURCE_RETRY=15 \
42
  YOUTUBE_FORCE_GAP=5 \
README.md CHANGED
@@ -8,7 +8,7 @@ pinned: false
8
  app_port: 7860
9
  ---
10
 
11
- # Reoo88/On — relay-v2.19-config-refresh
12
 
13
  رصد TikTok LIVE → FFmpeg → YouTube RTMPS.
14
  **لا تسجيل محلي، لا أرشيف فيديو، ولا ملفات فيديو على القرص.**
@@ -43,8 +43,10 @@ TikTok H.264 أو H.265/FLV
43
  - تغيّر `AVCDecoderConfigurationRecord` أو `HEVCDecoderConfigurationRecord` لا يرمي `FormatChanged` ولا يقتل FFmpeg: يمر السجل الجديد قبل الإطار الذي يحتاجه عبر RTMPS المفتوح نفسه.
44
  - `FLVBridge` يحافظ على بايتات الصورة وCTS، ويصل أزمنة DTS عند تجديد رابط HTTP-FLV من دون رأس FLV ثانٍ. سجل جديد كامل يحل محل السابق؛ لا تُخلط مجموعات قد تحمل IDs متعارضة.
45
  - FFmpeg 7 ينقل تغيير extradata من demuxer إلى muxer ويعيد كتابة SequenceStart؛ يثبت الاختبار الحتمي ذلك لـH.264 وH.265.
 
 
46
  - إذا بقي خيط الإشراف حيًا ومات FFmpeg لسبب آخر، يعيد watchdog تدويره بدل بقاء YouTube في `noData`.
47
- - روابط المصدر الموقعة تُرصد من جديد، مع حارس صمت افتراضي 12 ثانية. الوضع الافتراضي يمنع تحويل الفيديو؛ خيارات `matchgop` القديمة لا تعمل إلا إذا فعّلها المشغل ورفع `YOUTUBE_LIGHT_MAX` عمدًا.
48
  - الحماية من تكرار مذيع واحد ومن خلط مذيعين على مفتاح RTMPS بيئي واحد مفعلة.
49
  - الحالة والتوكنات تحفظ في Storage Bucket المركب على `/data`؛ كوكيز TikTok المخزنة تُكتب بصلاحية `0600`.
50
  - كوكي TikTok لا يُرسل إلى روابط CDN الموقعة خارج `tiktok.com`؛ طلبات الوسائط العامة تستعمل `Referer` فقط.
 
8
  app_port: 7860
9
  ---
10
 
11
+ # Reoo88/On — relay-v2.20-stream-monitor
12
 
13
  رصد TikTok LIVE → FFmpeg → YouTube RTMPS.
14
  **لا تسجيل محلي، لا أرشيف فيديو، ولا ملفات فيديو على القرص.**
 
43
  - تغيّر `AVCDecoderConfigurationRecord` أو `HEVCDecoderConfigurationRecord` لا يرمي `FormatChanged` ولا يقتل FFmpeg: يمر السجل الجديد قبل الإطار الذي يحتاجه عبر RTMPS المفتوح نفسه.
44
  - `FLVBridge` يحافظ على بايتات الصورة وCTS، ويصل أزمنة DTS عند تجديد رابط HTTP-FLV من دون رأس FLV ثانٍ. سجل جديد كامل يحل محل السابق؛ لا تُخلط مجموعات قد تحمل IDs متعارضة.
45
  - FFmpeg 7 ينقل تغيير extradata من demuxer إلى muxer ويعيد كتابة SequenceStart؛ يثبت الاختبار الحتمي ذلك لـH.264 وH.265.
46
+ - يسجل `stream_monitor.py` أحداث `CODEC_CHANGE` و`RESOLUTION_CHANGE` و`BITRATE_CHANGE` و`KEYFRAME_CHANGE` و`RECONNECT` و`CONNECTION_ERROR` و`YOUTUBE_HEALTH` في `logs/stream_events.json` داخل Storage Bucket، من دون وسائط أو روابط أو مفاتيح.
47
+ - يمكن قراءة آخر الأحداث من المسار المحمي `GET /api/stream-events?limit=200&broadcaster=user`، والملف محدود افتراضيًا إلى 10,000 سجل وصلاحيته `0600`.
48
  - إذا بقي خيط الإشراف حيًا ومات FFmpeg لسبب آخر، يعيد watchdog تدويره بدل بقاء YouTube في `noData`.
49
+ - روابط المصدر الموقعة تُرصد من جديد، مع حارس صمت افتراضي 12 ثانية. تُسحب صحة YouTube كل 20 ثانية أثناء الحدث وتدخل السجل. الوضع الافتراضي يمنع تحويل الفيديو؛ خيارات `matchgop` القديمة لا تعمل إلا إذا فعّلها المشغل ورفع `YOUTUBE_LIGHT_MAX` عمدًا.
50
  - الحماية من تكرار مذيع واحد ومن خلط مذيعين على مفتاح RTMPS بيئي واحد مفعلة.
51
  - الحالة والتوكنات تحفظ في Storage Bucket المركب على `/data`؛ كوكيز TikTok المخزنة تُكتب بصلاحية `0600`.
52
  - كوكي TikTok لا يُرسل إلى روابط CDN الموقعة خارج `tiktok.com`؛ طلبات الوسائط العامة تستعمل `Referer` فقط.
app.py CHANGED
@@ -47,6 +47,7 @@ try:
47
  ROOT.mkdir(parents=True,exist_ok=True)
48
  except OSError as e:
49
  logging.error('cannot create %s (%s) — starting read-only', ROOT, type(e).__name__)
 
50
 
51
  def _ensure_root():
52
  try:
@@ -441,6 +442,7 @@ def rank_sources(source):
441
 
442
  from youtube_manager import ManagedYouTube
443
  from relay import Sender, SourceStalled, RelayStartError
 
444
  def enabled_accounts():
445
  with lock:return [u for u,t in tasks.items() if t.get('enabled')]
446
  youtube=ManagedYouTube(FFMPEG,observe_session,log,enabled_accounts,ROOT/'youtube-api-state.json',headers_for=tiktok_headers)
@@ -906,6 +908,10 @@ def health_detail():
906
 
907
  @app.get('/api/youtube')
908
  def youtube_state():return youtube.snapshot()
 
 
 
 
909
  @app.post('/api/youtube/check')
910
  def youtube_check():
911
  try:return {'channel':youtube.check()}
 
47
  ROOT.mkdir(parents=True,exist_ok=True)
48
  except OSError as e:
49
  logging.error('cannot create %s (%s) — starting read-only', ROOT, type(e).__name__)
50
+ os.environ.setdefault('STREAM_EVENTS_PATH',str(ROOT/'logs'/'stream_events.json'))
51
 
52
  def _ensure_root():
53
  try:
 
442
 
443
  from youtube_manager import ManagedYouTube
444
  from relay import Sender, SourceStalled, RelayStartError
445
+ from stream_monitor import read_events
446
  def enabled_accounts():
447
  with lock:return [u for u,t in tasks.items() if t.get('enabled')]
448
  youtube=ManagedYouTube(FFMPEG,observe_session,log,enabled_accounts,ROOT/'youtube-api-state.json',headers_for=tiktok_headers)
 
908
 
909
  @app.get('/api/youtube')
910
  def youtube_state():return youtube.snapshot()
911
+ @app.get('/api/stream-events')
912
+ def stream_events(limit:int=200,broadcaster:str=''):
913
+ rows=read_events(limit,broadcaster.strip().lstrip('@') or None)
914
+ return {'count':len(rows),'events':rows,'file':'logs/stream_events.json'}
915
  @app.post('/api/youtube/check')
916
  def youtube_check():
917
  try:return {'channel':youtube.check()}
flv_bridge.py CHANGED
@@ -23,6 +23,11 @@ References:
23
  - FFmpeg 7.1 ``flvdec.c``/``flvenc.c`` NEW_EXTRADATA handling
24
  - GStreamer bugs 797255/797256 (stream headers/caps changes in streamable FLV)
25
  """
 
 
 
 
 
26
  class FormatChanged(Exception):
27
  """Compatibility name; decoder-configuration changes no longer raise it."""
28
 
@@ -50,7 +55,7 @@ def _set_tag_ts(header, ts):
50
 
51
 
52
  class FLVBridge:
53
- def __init__(self,detect_format_change=False,allow_raise=None):
54
  """Create a streaming bridge.
55
 
56
  ``detect_format_change`` and ``allow_raise`` remain accepted for callers from older
@@ -60,13 +65,43 @@ class FLVBridge:
60
  self.buffer=bytearray();self.header_done=False;self.converted_tags=0
61
  self.detect_format_change=bool(detect_format_change) # compatibility/observability only
62
  self.allow_raise=allow_raise
63
- self.video_cfg=None;self.video_codec=None;self.format_changes=0;self.codec_changes=0
 
 
64
  self.skip_file_header=False
65
  self.skip_script=False
66
  self.skip_same_cfg=False
67
  self.last_out_ts=None
68
  self.ts_bias=0
69
  self.ts_bias_pending=False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  def reconnect(self):
71
  """Prepare to splice a new TikTok FLV into the same FFmpeg stdin.
72
 
@@ -79,6 +114,8 @@ class FLVBridge:
79
  self.skip_script=True
80
  self.skip_same_cfg=True
81
  self.ts_bias_pending=self.last_out_ts is not None
 
 
82
  def feed(self,chunk):
83
  self.buffer.extend(chunk);output=bytearray()
84
  if not self.header_done:
@@ -96,7 +133,7 @@ class FLVBridge:
96
  if len(self.buffer)-offset<total:break
97
  body=bytes(self.buffer[offset+11:offset+11+size]);tag_type=header[0]
98
  if tag_type & 0x20:raise ValueError('Filtered/encrypted FLV tag is unsupported')
99
- kind = tag_type & 31
100
  if kind==18 and self.skip_script:
101
  offset+=total
102
  continue
@@ -114,13 +151,31 @@ class FLVBridge:
114
  if kind==9:
115
  signature=_video_signature(body)
116
  if signature is not None:
117
- previous=self.video_cfg
118
  changed=previous is not None and signature!=previous
 
 
119
  if changed:
120
  self.format_changes+=1
121
- if signature[:4]!=previous[:4]:self.codec_changes+=1
122
- self.video_cfg=signature
123
- self.video_codec=signature[:4].decode('ascii','replace')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  # Only the first identical record after reconnect is redundant. A changed
125
  # record is emitted now, before any dependent coded frame in this input.
126
  if self.skip_same_cfg:
@@ -128,7 +183,6 @@ class FLVBridge:
128
  if previous is not None and not changed:
129
  offset+=total
130
  continue
131
- raw_ts=_tag_ts(header)
132
  if self.ts_bias_pending:
133
  self.ts_bias=(self.last_out_ts or 0)+40-raw_ts
134
  self.ts_bias_pending=False
@@ -137,6 +191,11 @@ class FLVBridge:
137
  # Equal DTS across audio/video (or config/frame) is valid and must not be rewritten.
138
  # Only reconnect() adds one constant bias; last_out_ts tracks the emitted maximum.
139
  self.last_out_ts=out_ts if self.last_out_ts is None else max(self.last_out_ts,out_ts)
 
 
 
 
 
140
  header[1:4]=len(body).to_bytes(3,'big')
141
  output.extend(header);output.extend(body);output.extend((11+len(body)).to_bytes(4,'big'))
142
  offset+=total
 
23
  - FFmpeg 7.1 ``flvdec.c``/``flvenc.c`` NEW_EXTRADATA handling
24
  - GStreamer bugs 797255/797256 (stream headers/caps changes in streamable FLV)
25
  """
26
+ import os
27
+ from collections import deque
28
+ from stream_monitor import log_event, video_dimensions
29
+
30
+
31
  class FormatChanged(Exception):
32
  """Compatibility name; decoder-configuration changes no longer raise it."""
33
 
 
55
 
56
 
57
  class FLVBridge:
58
+ def __init__(self,detect_format_change=False,allow_raise=None,user='',event_logger=None):
59
  """Create a streaming bridge.
60
 
61
  ``detect_format_change`` and ``allow_raise`` remain accepted for callers from older
 
65
  self.buffer=bytearray();self.header_done=False;self.converted_tags=0
66
  self.detect_format_change=bool(detect_format_change) # compatibility/observability only
67
  self.allow_raise=allow_raise
68
+ self.user=str(user or 'system');self.event_logger=event_logger or log_event
69
+ self.video_cfg=None;self.video_codec=None;self.video_size=None
70
+ self.format_changes=0;self.codec_changes=0
71
  self.skip_file_header=False
72
  self.skip_script=False
73
  self.skip_same_cfg=False
74
  self.last_out_ts=None
75
  self.ts_bias=0
76
  self.ts_bias_pending=False
77
+ self._video_samples=deque();self._bitrate_kbps=None;self._bitrate_check_ts=None
78
+ self._last_keyframe_ts=None;self._keyframe_interval=None;self._keyframe_event_ts=None
79
+ def _event(self,event,details):
80
+ try:self.event_logger(self.user,event,details)
81
+ except Exception:pass
82
+ def _observe_frame(self,ts,size,keyframe):
83
+ samples=self._video_samples;samples.append((ts,size))
84
+ while samples and ts-samples[0][0]>4000:samples.popleft()
85
+ if len(samples)>=2 and samples[-1][0]-samples[0][0]>=3000 and (
86
+ self._bitrate_check_ts is None or ts-self._bitrate_check_ts>=2000):
87
+ span=max(1,samples[-1][0]-samples[0][0]);kbps=round(sum(x[1] for x in samples)*8/span,1)
88
+ old=self._bitrate_kbps;self._bitrate_kbps=kbps;self._bitrate_check_ts=ts
89
+ ratio=abs(kbps-old)/max(old,1) if old is not None else 0
90
+ threshold=max(0.1,float(os.getenv('STREAM_BITRATE_CHANGE_RATIO','0.35')))
91
+ minimum=max(1.0,float(os.getenv('STREAM_BITRATE_CHANGE_KBPS','50')))
92
+ if old is not None and ratio>=threshold and abs(kbps-old)>=minimum:
93
+ self._event('BITRATE_CHANGE',{'old_kbps':old,'new_kbps':kbps,
94
+ 'change_percent':round((kbps-old)*100/max(old,1),1),'source_dts_ms':ts})
95
+ if keyframe:
96
+ if self._last_keyframe_ts is not None and ts>self._last_keyframe_ts:
97
+ interval=ts-self._last_keyframe_ts;old=self._keyframe_interval
98
+ if old is not None and abs(interval-old)>=max(500,old*0.30) and (
99
+ self._keyframe_event_ts is None or ts-self._keyframe_event_ts>=5000):
100
+ self._event('KEYFRAME_CHANGE',{'old_interval_ms':round(old,1),
101
+ 'new_interval_ms':interval,'source_dts_ms':ts})
102
+ self._keyframe_event_ts=ts
103
+ self._keyframe_interval=interval if old is None else old*0.7+interval*0.3
104
+ self._last_keyframe_ts=ts
105
  def reconnect(self):
106
  """Prepare to splice a new TikTok FLV into the same FFmpeg stdin.
107
 
 
114
  self.skip_script=True
115
  self.skip_same_cfg=True
116
  self.ts_bias_pending=self.last_out_ts is not None
117
+ self._event('RECONNECT',{'scope':'tiktok_http','outbound_rtmp_reused':True,
118
+ 'last_output_dts_ms':self.last_out_ts})
119
  def feed(self,chunk):
120
  self.buffer.extend(chunk);output=bytearray()
121
  if not self.header_done:
 
133
  if len(self.buffer)-offset<total:break
134
  body=bytes(self.buffer[offset+11:offset+11+size]);tag_type=header[0]
135
  if tag_type & 0x20:raise ValueError('Filtered/encrypted FLV tag is unsupported')
136
+ kind = tag_type & 31;raw_ts=_tag_ts(header)
137
  if kind==18 and self.skip_script:
138
  offset+=total
139
  continue
 
151
  if kind==9:
152
  signature=_video_signature(body)
153
  if signature is not None:
154
+ previous=self.video_cfg;old_size=self.video_size
155
  changed=previous is not None and signature!=previous
156
+ codec=signature[:4].decode('ascii','replace')
157
+ new_size=video_dimensions(codec,signature[4:])
158
  if changed:
159
  self.format_changes+=1
160
+ old_codec=previous[:4].decode('ascii','replace')
161
+ codec_changed=codec!=old_codec
162
+ if codec_changed:
163
+ self.codec_changes+=1;self._video_samples.clear()
164
+ self._bitrate_kbps=None;self._last_keyframe_ts=None;self._keyframe_interval=None
165
+ details={'codec':codec,'previous_codec':old_codec,'source_dts_ms':raw_ts,
166
+ 'old_config_bytes':len(previous)-4,'new_config_bytes':len(signature)-4,
167
+ 'outbound_rtmp_reused':True}
168
+ self._event('DECODER_CONFIG_CHANGE',details)
169
+ if codec_changed:self._event('CODEC_CHANGE',details)
170
+ if old_size and new_size and old_size!=new_size:
171
+ self._event('RESOLUTION_CHANGE',{'codec':codec,
172
+ 'old_width':old_size[0],'old_height':old_size[1],
173
+ 'new_width':new_size[0],'new_height':new_size[1],
174
+ 'source_dts_ms':raw_ts,'outbound_rtmp_reused':True})
175
+ elif previous is None:
176
+ self._event('STREAM_CONFIG',{'codec':codec,'width':new_size[0] if new_size else None,
177
+ 'height':new_size[1] if new_size else None,'config_bytes':len(signature)-4})
178
+ self.video_cfg=signature;self.video_codec=codec;self.video_size=new_size
179
  # Only the first identical record after reconnect is redundant. A changed
180
  # record is emitted now, before any dependent coded frame in this input.
181
  if self.skip_same_cfg:
 
183
  if previous is not None and not changed:
184
  offset+=total
185
  continue
 
186
  if self.ts_bias_pending:
187
  self.ts_bias=(self.last_out_ts or 0)+40-raw_ts
188
  self.ts_bias_pending=False
 
191
  # Equal DTS across audio/video (or config/frame) is valid and must not be rewritten.
192
  # Only reconnect() adds one constant bias; last_out_ts tracks the emitted maximum.
193
  self.last_out_ts=out_ts if self.last_out_ts is None else max(self.last_out_ts,out_ts)
194
+ if kind==9 and body:
195
+ enhanced=bool(body[0]&0x80);packet=(body[0]&15) if enhanced else (body[1] if len(body)>1 else -1)
196
+ codec_id=body[0]&15
197
+ coded=(enhanced and packet in (1,3)) or (not enhanced and codec_id==7 and packet==1)
198
+ if coded:self._observe_frame(out_ts,len(body),((body[0]>>4)&7)==1)
199
  header[1:4]=len(body).to_bytes(3,'big')
200
  output.extend(header);output.extend(body);output.extend((11+len(body)).to_bytes(4,'big'))
201
  offset+=total
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.19-config-refresh'
10
 
11
 
12
  def env_rtmp_destination():
 
6
  from datetime import datetime
7
  from zoneinfo import ZoneInfo
8
 
9
+ APP_VERSION = 'relay-v2.20-stream-monitor'
10
 
11
 
12
  def env_rtmp_destination():
relay.py CHANGED
@@ -4,6 +4,7 @@ from collections import deque
4
  from urllib.parse import urlparse
5
  from curl_cffi import requests
6
  from flv_bridge import FLVBridge
 
7
 
8
 
9
  def source_http_headers(url, user=None, headers_for=None):
@@ -865,7 +866,7 @@ class Sender:
865
  hls = (candidate or {}).get('media') == 'hls'
866
  # Replacement SPS/PPS or VPS/SPS/PPS records are forwarded through this bridge and the
867
  # same FFmpeg/RTMPS publisher. They are not a reason to select x264 or restart output.
868
- bridge = FLVBridge()
869
  response = None
870
  p = None
871
  error_reader = None
@@ -879,7 +880,8 @@ class Sender:
879
  'src_metered': False,
880
  'force_stop': False, 'timeouts': 0, 'orphan': False, 'stable_notified': False, 'max_seconds': max_seconds,
881
  'stall_reason': '', 'spawned': False, 'spawn_error': '',
882
- 'writing': False, 'output_failure': False, 'ff_last': time.monotonic()}
 
883
  self.set(status='الاتصال بيوتيوب', source=candidate['label'], output_seconds=0,
884
  last_output_at=None, stalled=False, last_source_at=sess['last'], source_bytes=0)
885
  with self.lock:
@@ -901,6 +903,9 @@ class Sender:
901
  stderr=subprocess.PIPE, bufsize=0)
902
  sess['spawned'] = True
903
  sess['spawned_at'] = time.monotonic()
 
 
 
904
  with self.lock:
905
  self.process = p
906
  self.log(user, '[RELAY] بدء FFmpeg ← %s (%s؛ mode=%s)' % (candidate['label'],
@@ -1064,11 +1069,15 @@ class Sender:
1064
  if p is None:
1065
  sess['spawn_error'] = 'تعذر بدء FFmpeg/المصدر (%s)' % type(e).__name__
1066
  self.log(user, '[RELAY] فشل بدء الإرسال: %s' % sess['spawn_error'])
 
 
1067
  raise
1068
  except SourceStalled as e:
1069
  sess['stall_reason'] = str(e)
1070
  if not self.last_failure_side:
1071
  self.last_failure_side = 'source'
 
 
1072
  tag = 'خرج الإرسال' if self.last_failure_side == 'output' else 'المصدر'
1073
  self.log(user, '[WATCHDOG] توقف %s: %s' % (tag, e))
1074
  raise
@@ -1086,6 +1095,9 @@ class Sender:
1086
  self.set(status='', stalled=False, note='لم يبدأ FFmpeg أصلًا')
1087
  raise RelayStartError(sess.get('spawn_error') or 'تعذر بدء جلسة الإرسال')
1088
  _why = sess.get('stall_reason') or sess.get('error') or 'إيقاف عادي'
 
 
 
1089
  self._teardown(user, p, response, sess, stop, _why)
1090
  if error_reader:
1091
  error_reader.join(timeout=1)
 
4
  from urllib.parse import urlparse
5
  from curl_cffi import requests
6
  from flv_bridge import FLVBridge
7
+ from stream_monitor import log_event, note_rtmp_open
8
 
9
 
10
  def source_http_headers(url, user=None, headers_for=None):
 
866
  hls = (candidate or {}).get('media') == 'hls'
867
  # Replacement SPS/PPS or VPS/SPS/PPS records are forwarded through this bridge and the
868
  # same FFmpeg/RTMPS publisher. They are not a reason to select x264 or restart output.
869
+ bridge = FLVBridge(user=user)
870
  response = None
871
  p = None
872
  error_reader = None
 
880
  'src_metered': False,
881
  'force_stop': False, 'timeouts': 0, 'orphan': False, 'stable_notified': False, 'max_seconds': max_seconds,
882
  'stall_reason': '', 'spawned': False, 'spawn_error': '',
883
+ 'writing': False, 'output_failure': False, 'connection_event': False,
884
+ 'ff_last': time.monotonic()}
885
  self.set(status='الاتصال بيوتيوب', source=candidate['label'], output_seconds=0,
886
  last_output_at=None, stalled=False, last_source_at=sess['last'], source_bytes=0)
887
  with self.lock:
 
903
  stderr=subprocess.PIPE, bufsize=0)
904
  sess['spawned'] = True
905
  sess['spawned_at'] = time.monotonic()
906
+ if destination.startswith(('rtmp://','rtmps://')):
907
+ note_rtmp_open(user,{'mode':self.active_mode or self.send_mode(),
908
+ 'codec':'h265' if self.hevc_payload(candidate) else 'h264'})
909
  with self.lock:
910
  self.process = p
911
  self.log(user, '[RELAY] بدء FFmpeg ← %s (%s؛ mode=%s)' % (candidate['label'],
 
1069
  if p is None:
1070
  sess['spawn_error'] = 'تعذر بدء FFmpeg/المصدر (%s)' % type(e).__name__
1071
  self.log(user, '[RELAY] فشل بدء الإرسال: %s' % sess['spawn_error'])
1072
+ log_event(user,'CONNECTION_ERROR',{'scope':self.last_failure_side or 'startup',
1073
+ 'error_type':type(e).__name__,'stage':'open'});sess['connection_event']=True
1074
  raise
1075
  except SourceStalled as e:
1076
  sess['stall_reason'] = str(e)
1077
  if not self.last_failure_side:
1078
  self.last_failure_side = 'source'
1079
+ log_event(user,'CONNECTION_ERROR',{'scope':self.last_failure_side,
1080
+ 'error_type':type(e).__name__,'reason':str(e)[:300]});sess['connection_event']=True
1081
  tag = 'خرج الإرسال' if self.last_failure_side == 'output' else 'المصدر'
1082
  self.log(user, '[WATCHDOG] توقف %s: %s' % (tag, e))
1083
  raise
 
1095
  self.set(status='', stalled=False, note='لم يبدأ FFmpeg أصلًا')
1096
  raise RelayStartError(sess.get('spawn_error') or 'تعذر بدء جلسة الإرسال')
1097
  _why = sess.get('stall_reason') or sess.get('error') or 'إيقاف عادي'
1098
+ if self.last_failure_side=='output' and not sess.get('connection_event'):
1099
+ log_event(user,'CONNECTION_ERROR',{'scope':'youtube_rtmp','reason':_why[:300],
1100
+ 'ffmpeg_exit_code':p.poll()});sess['connection_event']=True
1101
  self._teardown(user, p, response, sess, stop, _why)
1102
  if error_reader:
1103
  error_reader.join(timeout=1)
stream_monitor.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Persistent, bounded diagnostics for byte-copy live streams.
2
+
3
+ Events are operational metadata only: no media, URLs, stream keys, cookies, or
4
+ encoded payloads are written. The JSON array lives under DATA_DIR/logs, appends
5
+ one compact object per event, and is periodically compacted with atomic replace.
6
+ """
7
+ import json
8
+ import os
9
+ import re
10
+ import threading
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+
15
+ class _Bits:
16
+ def __init__(self,data):
17
+ self.data=data;self.bit=0
18
+ def read(self,n):
19
+ if n<0 or self.bit+n>len(self.data)*8:raise ValueError('truncated bitstream')
20
+ value=0
21
+ for _ in range(n):
22
+ value=(value<<1)|((self.data[self.bit>>3]>>(7-(self.bit&7)))&1);self.bit+=1
23
+ return value
24
+ def ue(self):
25
+ zeros=0
26
+ while self.read(1)==0:
27
+ zeros+=1
28
+ if zeros>31:raise ValueError('invalid Exp-Golomb value')
29
+ return (1<<zeros)-1+(self.read(zeros) if zeros else 0)
30
+ def se(self):
31
+ value=self.ue()
32
+ return (value+1)//2 if value&1 else -(value//2)
33
+
34
+
35
+ def _rbsp(nal):
36
+ out=bytearray();zeros=0
37
+ for byte in nal:
38
+ if zeros>=2 and byte==3:
39
+ zeros=0;continue
40
+ out.append(byte)
41
+ zeros=zeros+1 if byte==0 else 0
42
+ return bytes(out)
43
+
44
+
45
+ def _skip_h264_scaling(bits,size):
46
+ last=next_scale=8
47
+ for j in range(size):
48
+ if next_scale:
49
+ next_scale=(last+bits.se()+256)%256
50
+ last=next_scale or last
51
+
52
+
53
+ def _h264_sps_size(nal):
54
+ data=_rbsp(nal[1:]);b=_Bits(data)
55
+ profile=b.read(8);b.read(8);b.read(8);b.ue()
56
+ chroma=1;separate=0
57
+ if profile in (44,83,86,100,110,118,122,128,138,139,134,135,144,244):
58
+ chroma=b.ue()
59
+ if chroma==3:separate=b.read(1)
60
+ b.ue();b.ue();b.read(1)
61
+ if b.read(1):
62
+ for i in range(8 if chroma!=3 else 12):
63
+ if b.read(1):_skip_h264_scaling(b,16 if i<6 else 64)
64
+ b.ue();poc=b.ue()
65
+ if poc==0:b.ue()
66
+ elif poc==1:
67
+ b.read(1);b.se();b.se()
68
+ for _ in range(b.ue()):b.se()
69
+ b.ue();b.read(1)
70
+ width_mbs=b.ue()+1;height_maps=b.ue()+1
71
+ frame_only=b.read(1)
72
+ if not frame_only:b.read(1)
73
+ b.read(1)
74
+ crop=(0,0,0,0)
75
+ if b.read(1):crop=tuple(b.ue() for _ in range(4))
76
+ chroma_array=0 if separate else chroma
77
+ sub_w=(1,2,2,1)[chroma_array];sub_h=(1,2,1,1)[chroma_array]
78
+ unit_x=1 if chroma_array==0 else sub_w
79
+ unit_y=(2-frame_only)*(1 if chroma_array==0 else sub_h)
80
+ width=width_mbs*16-(crop[0]+crop[1])*unit_x
81
+ height=(2-frame_only)*height_maps*16-(crop[2]+crop[3])*unit_y
82
+ return (width,height) if width>0 and height>0 else None
83
+
84
+
85
+ def _hevc_profile_tier_level(bits,max_sub_layers):
86
+ bits.read(96)
87
+ profiles=[];levels=[]
88
+ for _ in range(max_sub_layers):profiles.append(bits.read(1));levels.append(bits.read(1))
89
+ if max_sub_layers:
90
+ for _ in range(max_sub_layers,8):bits.read(2)
91
+ for profile,level in zip(profiles,levels):
92
+ if profile:bits.read(88)
93
+ if level:bits.read(8)
94
+
95
+
96
+ def _hevc_sps_size(nal):
97
+ data=_rbsp(nal[2:]);b=_Bits(data)
98
+ b.read(4);max_sub=b.read(3);b.read(1)
99
+ _hevc_profile_tier_level(b,max_sub)
100
+ b.ue();chroma=b.ue()
101
+ separate=b.read(1) if chroma==3 else 0
102
+ width=b.ue();height=b.ue();crop=(0,0,0,0)
103
+ if b.read(1):crop=tuple(b.ue() for _ in range(4))
104
+ chroma_array=0 if separate else chroma
105
+ sub_w=(1,2,2,1)[chroma_array];sub_h=(1,2,1,1)[chroma_array]
106
+ width-=(crop[0]+crop[1])*sub_w;height-=(crop[2]+crop[3])*sub_h
107
+ return (width,height) if width>0 and height>0 else None
108
+
109
+
110
+ def video_dimensions(codec,config):
111
+ """Read coded width/height from avcC or hvcC; malformed records return None."""
112
+ try:
113
+ if codec=='avc1':
114
+ if len(config)<7:return None
115
+ count=config[5]&31;offset=6
116
+ for _ in range(count):
117
+ size=int.from_bytes(config[offset:offset+2],'big');offset+=2
118
+ nal=config[offset:offset+size];offset+=size
119
+ if nal and nal[0]&31==7:return _h264_sps_size(nal)
120
+ return None
121
+ if codec=='hvc1':
122
+ if len(config)<23:return None
123
+ offset=23
124
+ for _ in range(config[22]):
125
+ nal_type=config[offset]&63;count=int.from_bytes(config[offset+1:offset+3],'big');offset+=3
126
+ for _ in range(count):
127
+ size=int.from_bytes(config[offset:offset+2],'big');offset+=2
128
+ nal=config[offset:offset+size];offset+=size
129
+ if nal_type==33:return _hevc_sps_size(nal)
130
+ except (IndexError,ValueError):
131
+ return None
132
+ return None
133
+
134
+
135
+ class StreamMonitor:
136
+ def __init__(self,path=None):
137
+ root=os.getenv('DATA_DIR') or ('/data/tiktok-relay' if Path('/data').is_dir() else './storage')
138
+ base=Path(root)
139
+ self.path=Path(path or os.getenv('STREAM_EVENTS_PATH') or base/'logs/stream_events.json')
140
+ try:self.maximum=max(100,min(int(os.getenv('STREAM_EVENT_MAX','10000')),100000))
141
+ except (TypeError,ValueError):self.maximum=10000
142
+ self.lock=threading.RLock();self.events=[];self.sequence=0;self.rtmp_opens={}
143
+ self._load()
144
+ def _load(self):
145
+ try:
146
+ data=json.loads(self.path.read_text())
147
+ if isinstance(data,list):
148
+ self.events=[x for x in data[-self.maximum:] if isinstance(x,dict)]
149
+ self.sequence=max((int(x.get('sequence') or 0) for x in self.events),default=0)
150
+ except (OSError,ValueError,TypeError):
151
+ pass
152
+ @staticmethod
153
+ def _safe(value,depth=0):
154
+ if depth>4:return '…'
155
+ if isinstance(value,dict):
156
+ return {str(k)[:80]:StreamMonitor._safe(v,depth+1) for k,v in value.items()
157
+ if not any(x in str(k).lower() for x in ('url','token','cookie','key','auth'))}
158
+ if isinstance(value,(list,tuple)):return [StreamMonitor._safe(x,depth+1) for x in value[:50]]
159
+ if isinstance(value,str):
160
+ value=re.sub(r'(?i)\b(?:rtmps?|https?)://\S+','«رابط-مخفي»',value)
161
+ value=re.sub(r'(?i)(sessionid|token|stream[_ -]?key|authorization)\s*[=:]\s*[^\s;&]+',
162
+ r'\1=«مخفي»',value)
163
+ return value[:500]
164
+ if isinstance(value,(int,float,bool)) or value is None:return value
165
+ return StreamMonitor._safe(str(value),depth+1)
166
+ def _rewrite(self):
167
+ self.path.parent.mkdir(parents=True,exist_ok=True)
168
+ tmp=self.path.with_name(self.path.name+'.tmp')
169
+ tmp.write_text(json.dumps(self.events,ensure_ascii=False,separators=(',',':')))
170
+ try:os.chmod(tmp,0o600)
171
+ except OSError:pass
172
+ os.replace(tmp,self.path)
173
+ def _append(self,item):
174
+ encoded=json.dumps(item,ensure_ascii=False,separators=(',',':')).encode()
175
+ try:
176
+ with self.path.open('r+b') as fh:
177
+ fh.seek(-1,os.SEEK_END)
178
+ if fh.read(1)!=b']':raise ValueError('invalid JSON tail')
179
+ fh.seek(-1,os.SEEK_END)
180
+ fh.write((b',' if len(self.events)>1 else b'')+encoded+b']');fh.truncate()
181
+ try:os.chmod(self.path,0o600)
182
+ except OSError:pass
183
+ except (OSError,ValueError):
184
+ self._rewrite()
185
+ def log(self,broadcaster,event,details=None):
186
+ try:
187
+ with self.lock:
188
+ self.sequence+=1
189
+ item={'timestamp':datetime.now(timezone.utc).isoformat(timespec='milliseconds').replace('+00:00','Z'),
190
+ 'sequence':self.sequence,'broadcaster':str(broadcaster or 'system')[:100],
191
+ 'event':str(event or 'UNKNOWN')[:80],'details':self._safe(details or {})}
192
+ self.events.append(item)
193
+ if len(self.events)>self.maximum:
194
+ self.events=self.events[-max(100,int(self.maximum*0.9)):];self._rewrite()
195
+ elif not self.path.exists():self._rewrite()
196
+ else:self._append(item)
197
+ return item
198
+ except (OSError,TypeError,ValueError):
199
+ return None
200
+ def read(self,limit=200,broadcaster=None):
201
+ try:limit=max(1,min(int(limit),2000))
202
+ except (TypeError,ValueError):limit=200
203
+ with self.lock:
204
+ rows=self.events
205
+ if broadcaster:rows=[x for x in rows if x.get('broadcaster')==broadcaster]
206
+ return [dict(x) for x in rows[-limit:]]
207
+ def note_rtmp_open(self,broadcaster,details=None):
208
+ user=str(broadcaster or 'system')
209
+ with self.lock:
210
+ count=self.rtmp_opens.get(user,0)+1;self.rtmp_opens[user]=count
211
+ if count>1:
212
+ merged={'scope':'youtube_rtmp','session_number':count};merged.update(details or {})
213
+ return self.log(user,'RECONNECT',merged)
214
+ return None
215
+
216
+
217
+ monitor=StreamMonitor()
218
+ def log_event(broadcaster,event,details=None):return monitor.log(broadcaster,event,details)
219
+ def read_events(limit=200,broadcaster=None):return monitor.read(limit,broadcaster)
220
+ def note_rtmp_open(broadcaster,details=None):return monitor.note_rtmp_open(broadcaster,details)
test_flv_bridge.py CHANGED
@@ -1,5 +1,6 @@
1
  #!/usr/bin/env python3
2
  """Deterministic decoder-record/reconnect tests; no network or transcoding."""
 
3
  import os
4
  import pathlib
5
  import shutil
@@ -10,6 +11,7 @@ import tempfile
10
  ROOT=pathlib.Path(sys.argv[1] if len(sys.argv)>1 else pathlib.Path(__file__).parent).resolve()
11
  sys.path.insert(0,str(ROOT))
12
  from flv_bridge import FLVBridge # noqa: E402
 
13
 
14
  passed=failed=0
15
  def check(name,ok,detail=''):
@@ -69,7 +71,9 @@ VIDEO_H=b'\x00\x00\x00\x04\x26\x01\xaf\x09'
69
 
70
  # A changed AVCDecoderConfigurationRecord must precede its dependent frame and must not raise,
71
  # even when an old caller still enables detect_format_change.
72
- b=FLVBridge(detect_format_change=True,allow_raise=lambda:True)
 
 
73
  avc_input=flv(tag(9,0,seq('avc1',AVC_A)),tag(9,0,coded('avc1',VIDEO_A,b'\xff\xff\xfe')),
74
  tag(9,40,seq('avc1',AVC_B)),tag(9,40,coded('avc1',VIDEO_A)))
75
  avc_out=chunked(b,avc_input);avc_tags=parse(avc_out)
@@ -81,6 +85,9 @@ check('AVC coded bytes and signed CTS pass unchanged',
81
  [x[2] for x in avc_tags if x[0]==9 and x[2][1:2]==b'\x01']==
82
  [coded('avc1',VIDEO_A,b'\xff\xff\xfe'),coded('avc1',VIDEO_A)])
83
  check('continuous-source DTS is preserved exactly',[x[1] for x in avc_tags]==[0,0,40,40])
 
 
 
84
 
85
  # A complete avcC may itself contain accumulated arrays. Preserve the record as a unit rather
86
  # than trying to append old records with potentially conflicting parameter-set IDs.
@@ -110,11 +117,15 @@ eh=parse(FLVBridge().feed(flv(tag(9,0,eh_seq),tag(9,1,eh_frame))))
110
  check('already-enhanced hvc1 bodies pass unchanged',[x[2] for x in eh]==[eh_seq,eh_frame])
111
 
112
  # Reattach suppresses only an identical startup record and preserves timestamp continuity.
113
- br=FLVBridge();first=br.feed(flv(tag(9,100,seq('avc1',AVC_A)),tag(9,140,coded('avc1',VIDEO_A))))
 
 
114
  last=br.last_out_ts;br.reconnect()
115
  second=br.feed(flv(tag(18,0,b'metadata'),tag(9,0,seq('avc1',AVC_A)),tag(9,40,coded('avc1',VIDEO_A))))
116
  joined=parse(first+second)
117
  check('reattach drops second FLV header and startup metadata',len(second)>0 and not second.startswith(b'FLV') and all(x[0]!=18 for x in joined))
 
 
118
  check('reattach drops only identical startup decoder record',sum(1 for x in joined if x[0]==9 and x[2][1:2]==b'\x00')==1)
119
  check('reattach shifts DTS once while preserving valid equal timestamps',
120
  br.last_out_ts>last and all(a[1]<=z[1] for a,z in zip(joined,joined[1:])))
@@ -129,12 +140,15 @@ check('changed reattach remains on one FLV byte stream',not part2.startswith(b'F
129
  # The byte bridge can represent a FourCC transition without raising or closing. FFmpeg 7 creates
130
  # a new late AVStream for such a transition, so production source selection still pins one codec;
131
  # same-codec decoder-record replacement above is the fully verified path.
132
- bs=FLVBridge(detect_format_change=True)
 
 
133
  switch=bs.feed(flv(tag(9,0,seq('avc1',AVC_A)),tag(9,1,coded('avc1',VIDEO_A)),
134
  tag(9,40,seq('hvc1',HEVC_A,legacy=False)),tag(9,41,coded('hvc1',VIDEO_H,legacy=False))))
135
  switch_tags=parse(switch)
136
  check('AVC to HEVC transition does not raise or truncate bridge output',len(switch_tags)==4)
137
  check('codec transition is separately observable',bs.format_changes==1 and bs.codec_changes==1 and bs.video_codec=='hvc1')
 
138
 
139
  try:
140
  FLVBridge().feed(flv(tag(9,0,b'\x90hvc1')))
@@ -143,6 +157,32 @@ except ValueError:
143
  truncated=True
144
  check('truncated Enhanced SequenceStart fails in a controlled way',truncated)
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  # FFmpeg 7 is the real outbound middle layer. If available, prove its FLV demux/mux pair writes
147
  # replacement AVC and HEVC configuration records instead of fixing extradata at startup.
148
  ff=os.environ.get('FFMPEG_PATH') or shutil.which('ffmpeg')
 
1
  #!/usr/bin/env python3
2
  """Deterministic decoder-record/reconnect tests; no network or transcoding."""
3
+ import json
4
  import os
5
  import pathlib
6
  import shutil
 
11
  ROOT=pathlib.Path(sys.argv[1] if len(sys.argv)>1 else pathlib.Path(__file__).parent).resolve()
12
  sys.path.insert(0,str(ROOT))
13
  from flv_bridge import FLVBridge # noqa: E402
14
+ from stream_monitor import StreamMonitor, video_dimensions # noqa: E402
15
 
16
  passed=failed=0
17
  def check(name,ok,detail=''):
 
71
 
72
  # A changed AVCDecoderConfigurationRecord must precede its dependent frame and must not raise,
73
  # even when an old caller still enables detect_format_change.
74
+ events=[]
75
+ b=FLVBridge(detect_format_change=True,allow_raise=lambda:True,user='alice',
76
+ event_logger=lambda user,event,details:events.append((user,event,details)))
77
  avc_input=flv(tag(9,0,seq('avc1',AVC_A)),tag(9,0,coded('avc1',VIDEO_A,b'\xff\xff\xfe')),
78
  tag(9,40,seq('avc1',AVC_B)),tag(9,40,coded('avc1',VIDEO_A)))
79
  avc_out=chunked(b,avc_input);avc_tags=parse(avc_out)
 
85
  [x[2] for x in avc_tags if x[0]==9 and x[2][1:2]==b'\x01']==
86
  [coded('avc1',VIDEO_A,b'\xff\xff\xfe'),coded('avc1',VIDEO_A)])
87
  check('continuous-source DTS is preserved exactly',[x[1] for x in avc_tags]==[0,0,40,40])
88
+ check('AVC config and resolution changes are recorded with broadcaster identity',
89
+ [x[1] for x in events]==['STREAM_CONFIG','DECODER_CONFIG_CHANGE','RESOLUTION_CHANGE'] and
90
+ events[-1][0]=='alice' and events[-1][2]['new_width']==640)
91
 
92
  # A complete avcC may itself contain accumulated arrays. Preserve the record as a unit rather
93
  # than trying to append old records with potentially conflicting parameter-set IDs.
 
117
  check('already-enhanced hvc1 bodies pass unchanged',[x[2] for x in eh]==[eh_seq,eh_frame])
118
 
119
  # Reattach suppresses only an identical startup record and preserves timestamp continuity.
120
+ reconnect_events=[]
121
+ br=FLVBridge(user='alice',event_logger=lambda user,event,details:reconnect_events.append((event,details)))
122
+ first=br.feed(flv(tag(9,100,seq('avc1',AVC_A)),tag(9,140,coded('avc1',VIDEO_A))))
123
  last=br.last_out_ts;br.reconnect()
124
  second=br.feed(flv(tag(18,0,b'metadata'),tag(9,0,seq('avc1',AVC_A)),tag(9,40,coded('avc1',VIDEO_A))))
125
  joined=parse(first+second)
126
  check('reattach drops second FLV header and startup metadata',len(second)>0 and not second.startswith(b'FLV') and all(x[0]!=18 for x in joined))
127
+ check('source reattach is recorded without claiming RTMP closure',
128
+ reconnect_events[-1][0]=='RECONNECT' and reconnect_events[-1][1]['outbound_rtmp_reused'] is True)
129
  check('reattach drops only identical startup decoder record',sum(1 for x in joined if x[0]==9 and x[2][1:2]==b'\x00')==1)
130
  check('reattach shifts DTS once while preserving valid equal timestamps',
131
  br.last_out_ts>last and all(a[1]<=z[1] for a,z in zip(joined,joined[1:])))
 
140
  # The byte bridge can represent a FourCC transition without raising or closing. FFmpeg 7 creates
141
  # a new late AVStream for such a transition, so production source selection still pins one codec;
142
  # same-codec decoder-record replacement above is the fully verified path.
143
+ switch_events=[]
144
+ bs=FLVBridge(detect_format_change=True,user='alice',
145
+ event_logger=lambda user,event,details:switch_events.append(event))
146
  switch=bs.feed(flv(tag(9,0,seq('avc1',AVC_A)),tag(9,1,coded('avc1',VIDEO_A)),
147
  tag(9,40,seq('hvc1',HEVC_A,legacy=False)),tag(9,41,coded('hvc1',VIDEO_H,legacy=False))))
148
  switch_tags=parse(switch)
149
  check('AVC to HEVC transition does not raise or truncate bridge output',len(switch_tags)==4)
150
  check('codec transition is separately observable',bs.format_changes==1 and bs.codec_changes==1 and bs.video_codec=='hvc1')
151
+ check('codec transition writes CODEC_CHANGE without stopping the bridge','CODEC_CHANGE' in switch_events)
152
 
153
  try:
154
  FLVBridge().feed(flv(tag(9,0,b'\x90hvc1')))
 
157
  truncated=True
158
  check('truncated Enhanced SequenceStart fails in a controlled way',truncated)
159
 
160
+ # Metric transitions are inferred from tag bytes/timestamps, never by decoding the picture.
161
+ metric_events=[];records=[tag(9,0,seq('avc1',AVC_A))]
162
+ for ts in range(0,10000,40):
163
+ frame=1 if ts in (0,2000,4000,5000,6000,7000,8000,9000) else 2
164
+ payload=(b'x'*100) if ts<5000 else (b'y'*1000)
165
+ records.append(tag(9,ts,coded('avc1',payload,frame=frame)))
166
+ FLVBridge(user='alice',event_logger=lambda user,event,details:metric_events.append(event)).feed(flv(*records))
167
+ check('byte-level monitor detects a sudden bitrate change','BITRATE_CHANGE' in metric_events)
168
+ check('byte-level monitor detects a keyframe-interval change','KEYFRAME_CHANGE' in metric_events)
169
+ check('AVC and HEVC SPS dimensions are parsed without decoding',
170
+ video_dimensions('avc1',AVC_A)==(320,240) and video_dimensions('avc1',AVC_B)==(640,360) and
171
+ video_dimensions('hvc1',HEVC_A)==(320,240) and video_dimensions('hvc1',HEVC_B)==(640,360))
172
+
173
+ with tempfile.TemporaryDirectory() as mon_dir:
174
+ mon_path=pathlib.Path(mon_dir)/'logs'/'stream_events.json';mon=StreamMonitor(mon_path)
175
+ mon.log('alice','CONNECTION_ERROR',{'scope':'youtube_rtmp','url':'secret-url',
176
+ 'stream_key':'secret-key','reason':'failed https://secret-host/live/SECRET'})
177
+ mon.note_rtmp_open('alice');mon.note_rtmp_open('alice',{'mode':'copy'})
178
+ saved=json.loads(mon_path.read_text())
179
+ check('stream event file is valid JSON with the required schema',
180
+ len(saved)==2 and all(set(('timestamp','broadcaster','event','details'))<=set(x) for x in saved))
181
+ check('stream monitor never persists URLs, keys or tokens',
182
+ all(x not in mon_path.read_text() for x in ('secret-url','secret-key','secret-host','SECRET')))
183
+ check('stream event file is owner-only',mon_path.stat().st_mode&0o777==0o600)
184
+ check('second outbound publisher open is recorded as RECONNECT',saved[-1]['event']=='RECONNECT')
185
+
186
  # FFmpeg 7 is the real outbound middle layer. If available, prove its FLV demux/mux pair writes
187
  # replacement AVC and HEVC configuration records instead of fixing extradata at startup.
188
  ff=os.environ.get('FFMPEG_PATH') or shutil.which('ffmpeg')
test_regressions.py CHANGED
@@ -129,11 +129,14 @@ with TestClient(appmod.app) as client:
129
  unauth=client.get('/api/state')
130
  wrong=client.get('/api/state',auth=('admin','wrong'))
131
  right=client.get('/api/state',auth=('admin',os.environ['PASSWORD']))
 
132
  check('embedded root shows login instead of raw 401', root.status_code==200 and 'login-panel' in root.text)
133
  check('public health contains no broadcaster/account details', set(health)<= {'status','application','monitor','version'}, str(health))
134
  check('operational API rejects anonymous access', unauth.status_code==401)
135
  check('operational API rejects a wrong password', wrong.status_code==401)
136
  check('operational API accepts the configured password', right.status_code==200)
 
 
137
 
138
  appmod.set_token('alice','sessionid='+'x'*32)
139
  mode=stat.S_IMODE(appmod.TOKENS.stat().st_mode)
 
129
  unauth=client.get('/api/state')
130
  wrong=client.get('/api/state',auth=('admin','wrong'))
131
  right=client.get('/api/state',auth=('admin',os.environ['PASSWORD']))
132
+ stream_events=client.get('/api/stream-events',auth=('admin',os.environ['PASSWORD']))
133
  check('embedded root shows login instead of raw 401', root.status_code==200 and 'login-panel' in root.text)
134
  check('public health contains no broadcaster/account details', set(health)<= {'status','application','monitor','version'}, str(health))
135
  check('operational API rejects anonymous access', unauth.status_code==401)
136
  check('operational API rejects a wrong password', wrong.status_code==401)
137
  check('operational API accepts the configured password', right.status_code==200)
138
+ check('authenticated stream-event API exposes the bounded JSON log',
139
+ stream_events.status_code==200 and stream_events.json().get('file')=='logs/stream_events.json')
140
 
141
  appmod.set_token('alice','sessionid='+'x'*32)
142
  mode=stat.S_IMODE(appmod.TOKENS.stat().st_mode)
test_relay.py CHANGED
@@ -392,7 +392,9 @@ check('Dockerfile defaults to byte-copy AVC/Enhanced-HEVC without forced x264',
392
  'YOUTUBE_LIGHT_MAX=0' in df and
393
  'YOUTUBE_AUTO_TRANSCODE=0' in df and
394
  'YOUTUBE_FORCE_TRANSCODE_USERS=' not in df and
395
- 'YOUTUBE_ENHANCED_RTMP=auto' in df)
 
 
396
  mgr_src=(ROOT/'youtube_manager.py').read_text()
397
  check('run_manager calls restart_dead_senders', 'restart_dead_senders' in mgr_src.split('def run_manager')[1])
398
  pump=(ROOT/'relay.py').read_text()
 
392
  'YOUTUBE_LIGHT_MAX=0' in df and
393
  'YOUTUBE_AUTO_TRANSCODE=0' in df and
394
  'YOUTUBE_FORCE_TRANSCODE_USERS=' not in df and
395
+ 'YOUTUBE_ENHANCED_RTMP=auto' in df and
396
+ 'stream_monitor.py' in df and 'YOUTUBE_HEALTH_INTERVAL=20' in df and
397
+ 'STREAM_EVENT_MAX=10000' in df)
398
  mgr_src=(ROOT/'youtube_manager.py').read_text()
399
  check('run_manager calls restart_dead_senders', 'restart_dead_senders' in mgr_src.split('def run_manager')[1])
400
  pump=(ROOT/'relay.py').read_text()
youtube_manager.py CHANGED
@@ -8,6 +8,7 @@ from zoneinfo import ZoneInfo
8
  from urllib.parse import urlparse
9
  from curl_cffi import requests
10
  from relay import RelayStartError, Sender, SourceStalled, source_http_headers
 
11
  import inspect
12
 
13
  from youtube_api import GoogleAPI, Journal, APIError
@@ -1758,7 +1759,7 @@ class ManagedYouTube:
1758
  (videoResolutionUnsupported / videoCodec) and why playback stalls (gopSizeLong /
1759
  openGop). Surfacing it replaces guesswork with ground truth. Polled on a slower
1760
  cadence than the life-cycle check so it stays inside the quota budget."""
1761
- interval=max(45,int(os.getenv('YOUTUBE_HEALTH_INTERVAL','60')))
1762
  if time.time()-float(ev.get('last_health') or 0)<interval:return
1763
  stream_id=(self.journal.read()['streams'].get(user) or {}).get('id')
1764
  if not stream_id:return
@@ -1774,6 +1775,8 @@ class ManagedYouTube:
1774
  text=' | '.join(issues)
1775
  prev=ev.get('last_issues')
1776
  status=str(health.get('status') or '')
 
 
1777
  self.event_update(key,youtube_health=status,youtube_issues=text,last_issues=text)
1778
  if text and text!=prev:
1779
  self.log(user,'يوتيوب يشخّص البث: '+text)
 
8
  from urllib.parse import urlparse
9
  from curl_cffi import requests
10
  from relay import RelayStartError, Sender, SourceStalled, source_http_headers
11
+ from stream_monitor import log_event
12
  import inspect
13
 
14
  from youtube_api import GoogleAPI, Journal, APIError
 
1759
  (videoResolutionUnsupported / videoCodec) and why playback stalls (gopSizeLong /
1760
  openGop). Surfacing it replaces guesswork with ground truth. Polled on a slower
1761
  cadence than the life-cycle check so it stays inside the quota budget."""
1762
+ interval=max(15,int(os.getenv('YOUTUBE_HEALTH_INTERVAL','20')))
1763
  if time.time()-float(ev.get('last_health') or 0)<interval:return
1764
  stream_id=(self.journal.read()['streams'].get(user) or {}).get('id')
1765
  if not stream_id:return
 
1775
  text=' | '.join(issues)
1776
  prev=ev.get('last_issues')
1777
  status=str(health.get('status') or '')
1778
+ log_event(user,'YOUTUBE_HEALTH',{'status':status or 'unknown','issues':issues,
1779
+ 'issue_count':len(issues)})
1780
  self.event_update(key,youtube_health=status,youtube_issues=text,last_issues=text)
1781
  if text and text!=prev:
1782
  self.log(user,'يوتيوب يشخّص البث: '+text)