Arena maintenance commited on
Commit ·
dc5fd17
1
Parent(s): 1de09cd
Persist live stream transition diagnostics
Browse files- AUDIT_AR.md +22 -4
- Dockerfile +3 -2
- README.md +4 -2
- app.py +6 -0
- flv_bridge.py +67 -8
- ingest.py +1 -1
- relay.py +14 -2
- stream_monitor.py +220 -0
- test_flv_bridge.py +43 -3
- test_regressions.py +3 -0
- test_relay.py +3 -1
- youtube_manager.py +4 -1
AUDIT_AR.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# تقرير تدقيق وإصلاح Space `Reoo88/On`
|
| 2 |
|
| 3 |
-
**التاريخ:** 2026-10-07 — **النسخة المنشورة قبل هذا التغيير:** `
|
| 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، والنتيجة **
|
| 212 |
|
| 213 |
- فحص بنيوي واحد يؤكد غياب التسجيل وواجهة تنزيل الفيديو.
|
| 214 |
- 25 فحص تزامن وعزل حسابات.
|
| 215 |
-
-
|
| 216 |
- 83 فحص relay/API/UI، ومنها غياب أي runtime caller لـ`FormatChanged` وافتراضات Docker الخاصة بالنسخ.
|
| 217 |
-
-
|
| 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=
|
|
|
|
| 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.
|
| 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.
|
|
|
|
|
|
|
| 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 |
-
|
| 122 |
-
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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, '
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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(
|
| 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)
|