Spaces:
Running
Running
V11: إلغاء البث المباشر + إصلاح تشويه الصورة في الجولات الرباعية
Browse filesإلغاء البث المباشر: حذف youtube_manager.py و relay.py و flv_bridge.py و test_concurrency.py و verify.sh ولوحة مدير الأحداث ومسارات /api/youtube/start|stop|settings. بديلها youtube_core.py يحمل OAuth والكوتا والقناة للأرشفة فقط.
إصلاح الجولات الرباعية: FLVWriter صار يتتبع إعداد الصورة الفعال (لا أول إعداد) فلا يُسقط وسم العودة بعد الجولة، وعند تعدد إعدادات الصورة يُبنى MP4 مباشرة من FLV بإطار ثابت على المقاس الغالب مع حفظ النسبة بدل تمريره على mkvmerge الذي يفقد الإعداد الثاني.
التسجيلات ذات المقاس الواحد تبقى نسخًا بلا إعادة ترميز. اختبارات: test_archive.py 19/19 و test_geometry.py 7/7.
- Dockerfile +31 -47
- README.md +70 -14
- VARIABLES.md +43 -18
- app.py +182 -70
- deploy_hode_arch.py +5 -2
- flv_bridge.py +0 -82
- index.html +8 -26
- relay.py +0 -191
- test_concurrency.py +0 -329
- test_geometry.py +249 -0
- variables.env +10 -14
- verify.sh +0 -139
- youtube_core.py +98 -0
- youtube_manager.py +0 -620
Dockerfile
CHANGED
|
@@ -1,55 +1,39 @@
|
|
| 1 |
FROM python:3.11-slim-trixie
|
| 2 |
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg mkvtoolnix ca-certificates tzdata && rm -rf /var/lib/apt/lists/*
|
| 3 |
-
|
|
|
|
|
|
|
| 4 |
WORKDIR /app
|
| 5 |
COPY requirements.txt .
|
| 6 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
-
COPY app.py
|
| 8 |
|
| 9 |
-
# ---
|
| 10 |
-
#
|
| 11 |
-
#
|
| 12 |
-
#
|
| 13 |
-
#
|
| 14 |
-
#
|
| 15 |
-
#
|
| 16 |
-
|
| 17 |
-
#
|
| 18 |
-
#
|
| 19 |
-
#
|
| 20 |
-
#
|
| 21 |
-
#
|
| 22 |
-
#
|
| 23 |
-
#
|
| 24 |
-
#
|
| 25 |
-
# behaviour: 1-3 s gap, but YouTube re-declares the new size explicitly).
|
| 26 |
-
# YOUTUBE_FLAP_LIMIT: if resolution flips this many times in 10 minutes, copy mode
|
| 27 |
-
# falls back to YOUTUBE_FLAP_FALLBACK_MODE for the rest of the event. Dormant
|
| 28 |
-
# while the guard is off, kept as a safety net.
|
| 29 |
#
|
| 30 |
-
#
|
| 31 |
-
#
|
| 32 |
-
#
|
| 33 |
-
#
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
# destroyed instead of delayed. relay.py sizes it automatically (ts*250+500).
|
| 40 |
-
# YOUTUBE_HEALTH_INTERVAL: seconds between reads of YouTube's own
|
| 41 |
-
# healthStatus/configurationIssues while live. This is what reports videoIngestion
|
| 42 |
-
# Starved (viewers buffer), gopSizeLong, openGop, videoResolutionUnsupported and
|
| 43 |
-
# videoCodec, i.e. the ground truth instead of a guess.
|
| 44 |
-
ENV YOUTUBE_SEND_MODE=copy \
|
| 45 |
-
YOUTUBE_FORMAT_GUARD=off \
|
| 46 |
-
YOUTUBE_FLAP_LIMIT=4 \
|
| 47 |
-
YOUTUBE_FLAP_FALLBACK_MODE=fixed720 \
|
| 48 |
-
YOUTUBE_X264_PRESET=veryfast \
|
| 49 |
-
YOUTUBE_CRF=22 \
|
| 50 |
-
YOUTUBE_RECOVERY_WAIT=1 \
|
| 51 |
-
YOUTUBE_FIFO_TIMESHIFT=10 \
|
| 52 |
-
YOUTUBE_HEALTH_INTERVAL=180
|
| 53 |
|
| 54 |
# --- TikTok session token (age-restricted 18+ rooms) -------------------------
|
| 55 |
# Set TIKTOK_SESSIONID in Settings > Variables and secrets, or paste it into the
|
|
@@ -69,7 +53,7 @@ ENV TIKTOK_TT_TARGET_IDC=useast1a
|
|
| 69 |
# اجتياز تدقيق YouTube API Services. اللوحة تعرض الخصوصية الفعلية لا المطلوبة.
|
| 70 |
# ARCHIVE_FALLBACK_PRIVACY: إن لم تُقبل الخصوصية المطلوبة نحاول هذه (unlisted).
|
| 71 |
# ARCHIVE_UPLOAD_COST: كلفة videos.insert = 1600 وحدة. الحصة الافتراضية 10000/يوم
|
| 72 |
-
# ⇒ ~6 رفعات يوميًا فقط، و
|
| 73 |
# عند النفاد يبقى الملف في الطابور وينتظر تجديد الكوتا (منتصف ليل المحيط الهادئ).
|
| 74 |
# ARCHIVE_PROTECT_QUEUE=on: التنظيف الذكي ممنوع من حذف أي ملف لم يُرفع بعد.
|
| 75 |
# اجعلها off إذا فضّلت استمرار التسجيل على حساب فقدان الأقدم عند امتلاء القرص.
|
|
@@ -91,6 +75,6 @@ ENV ARCHIVE_ENABLED=on \
|
|
| 91 |
ARCHIVE_MADE_FOR_KIDS=off \
|
| 92 |
YOUTUBE_DAILY_BUDGET=9000
|
| 93 |
|
| 94 |
-
ENV PORT=7860 PYTHONUNBUFFERED=1
|
| 95 |
EXPOSE 7860
|
| 96 |
CMD ["sh", "-c", "exec uvicorn app:app --host 0.0.0.0 --port ${PORT:-7860} --timeout-graceful-shutdown 65"]
|
|
|
|
| 1 |
FROM python:3.11-slim-trixie
|
| 2 |
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg mkvtoolnix ca-certificates tzdata && rm -rf /var/lib/apt/lists/*
|
| 3 |
+
# فحص بناء: libx264 لازم لتوحيد الإطار عند الجولات الرباعية فقط (المسار العادي نسخ بلا ترميز).
|
| 4 |
+
# فحص rtmp_enhanced_codecs حُذف لأن الدفع المباشر إلى يوتيوب لم يعد موجودًا.
|
| 5 |
+
RUN ffmpeg -hide_banner -encoders 2>/dev/null | grep -q libx264
|
| 6 |
WORKDIR /app
|
| 7 |
COPY requirements.txt .
|
| 8 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 9 |
+
COPY app.py youtube_api.py youtube_core.py youtube_archive.py tiktok_auth.py index.html ./
|
| 10 |
|
| 11 |
+
# --- البث المباشر: ملغى من المشروع ------------------------------------------
|
| 12 |
+
# حُذفت وحدات البث المباشر (youtube_manager.py لإدارة الأحداث، relay.py للدفع إلى
|
| 13 |
+
# RTMPS، flv_bridge.py لجسر البثّ) ومعها كل متغيّراتها:
|
| 14 |
+
# YOUTUBE_SEND_MODE / YOUTUBE_FORMAT_GUARD / YOUTUBE_FLAP_* / YOUTUBE_FIFO_TIMESHIFT
|
| 15 |
+
# YOUTUBE_HEALTH_INTERVAL / YOUTUBE_X264_PRESET / YOUTUBE_CRF / YOUTUBE_RECOVERY_WAIT
|
| 16 |
+
# YOUTUBE_MAX_EVENTS / YOUTUBE_SCAN_SECONDS / YOUTUBE_END_GRACE / YOUTUBE_VIDEO_MODE
|
| 17 |
+
# وجودها في Settings لا يضر ولا يفعل شيئًا. التطبيق الآن: مراقبة ← تسجيل ← أرشفة.
|
| 18 |
+
|
| 19 |
+
# --- إصلاح الجولات الرباعية: توحيد الإطار عند تغيّر مقاس الصورة ---------------
|
| 20 |
+
# المشكلة المقيسة: عند دخول المذيع جولة متعددة المذيعين يبدّل تيك توك تخطيط البث
|
| 21 |
+
# ويرسل إعداد صورة جديدًا (SPS/مقاس مختلف) وسط نفس الاتصال ثم يعود للأول. ملف FLV
|
| 22 |
+
# الخام سليم (صفر خطأ فك ترميز)، لكن mkvmerge لا يحمل إلا إعدادًا واحدًا للمسار،
|
| 23 |
+
# فأُنتج في ملف اختبار فيه تغيّر واحد 150 خطأ فك ترميز = الصورة المشوّهة في التسجيل.
|
| 24 |
+
# الحل: نرصد تعدد الإعدادات، ونبني MP4 مباشرة من FLV على إطار ثابت = المقاس الغالب،
|
| 25 |
+
# مع scale(decrease)+pad+setsar=1 فتُحفظ نسبة كل مشهد بلا تمطيط. التسجيلات التي لا
|
| 26 |
+
# يتغيّر فيها المقاس تبقى على مسارها القديم: mkvmerge ثم -c copy بلا إعادة ترميز.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
#
|
| 28 |
+
# GEOMETRY_SCAN=auto : التوحيد عند رصد المسجّل تغيّر الإعداد (افتراضي، بلا كلفة زائدة)
|
| 29 |
+
# on : يفحص كل تسجيل بـffprobe على الإطارات المفتاحية أيضًا
|
| 30 |
+
# off : يُعطّل الإصلاح ويعود السلوك القديم حرفيًا
|
| 31 |
+
# GEOMETRY_FIX=normalize|off GEOMETRY_PRESET/GEOMETRY_CRF: جودة إعادة الترميز عند اللزوم
|
| 32 |
+
ENV GEOMETRY_SCAN=auto \
|
| 33 |
+
GEOMETRY_FIX=normalize \
|
| 34 |
+
GEOMETRY_PRESET=veryfast \
|
| 35 |
+
GEOMETRY_CRF=20 \
|
| 36 |
+
GEOMETRY_PAD_COLOR=black
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
# --- TikTok session token (age-restricted 18+ rooms) -------------------------
|
| 39 |
# Set TIKTOK_SESSIONID in Settings > Variables and secrets, or paste it into the
|
|
|
|
| 53 |
# اجتياز تدقيق YouTube API Services. اللوحة تعرض الخصوصية الفعلية لا المطلوبة.
|
| 54 |
# ARCHIVE_FALLBACK_PRIVACY: إن لم تُقبل الخصوصية المطلوبة نحاول هذه (unlisted).
|
| 55 |
# ARCHIVE_UPLOAD_COST: كلفة videos.insert = 1600 وحدة. الحصة الافتراضية 10000/يوم
|
| 56 |
+
# ⇒ ~6 رفعات يوميًا فقط، ويحرسها السقف المحلي YOUTUBE_DAILY_BUDGET.
|
| 57 |
# عند النفاد يبقى الملف في الطابور وينتظر تجديد الكوتا (منتصف ليل المحيط الهادئ).
|
| 58 |
# ARCHIVE_PROTECT_QUEUE=on: التنظيف الذكي ممنوع من حذف أي ملف لم يُرفع بعد.
|
| 59 |
# اجعلها off إذا فضّلت استمرار التسجيل على حساب فقدان الأقدم عند امتلاء القرص.
|
|
|
|
| 75 |
ARCHIVE_MADE_FOR_KIDS=off \
|
| 76 |
YOUTUBE_DAILY_BUDGET=9000
|
| 77 |
|
| 78 |
+
ENV PORT=7860 PYTHONUNBUFFERED=1 PASSWORD=admin
|
| 79 |
EXPOSE 7860
|
| 80 |
CMD ["sh", "-c", "exec uvicorn app:app --host 0.0.0.0 --port ${PORT:-7860} --timeout-graceful-shutdown 65"]
|
README.md
CHANGED
|
@@ -6,7 +6,7 @@ colorFrom: pink
|
|
| 6 |
colorTo: gray
|
| 7 |
---
|
| 8 |
|
| 9 |
-
# مرصد البثوث — تسجيل أصلي + أرشفة تلقائية إلى يوتيوب (
|
| 10 |
|
| 11 |
يراقب حسابات تيك توك على مدار الساعة، ويسجّل كل بث **بجودته الأصلية بلا إعادة ترميز
|
| 12 |
(copy)**، ثم **يرفع التسجيل إلى يوتيوب تلقائيًا ويحذفه من التخزين بعد تأكيد الرفع** —
|
|
@@ -20,18 +20,66 @@ colorTo: gray
|
|
| 20 |
```
|
| 21 |
|
| 22 |
لا إعادة ترميز في أي خطوة: الصورة التي تصل يوتيوب هي بايتات تيك توك نفسها.
|
|
|
|
|
|
|
| 23 |
|
| 24 |
-
## ما الجديد في
|
| 25 |
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|---|---|
|
| 28 |
-
| `
|
| 29 |
-
|
|
| 30 |
-
| `
|
| 31 |
-
|
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
-
### ضمانات مُختبَرة
|
| 35 |
- **لا حذف قبل التأكيد:** الحذف لا يقع إلا بعد أن يردّ `videos.list` بـ`uploadStatus`
|
| 36 |
يساوي `uploaded` أو `processed`. رفض يوتيوب ⇒ الملف يبقى والسبب يظهر في اللوحة.
|
| 37 |
- **لا رفع مزدوج:** جلسة الرفع تُحفظ؛ الانقطاع يُستأنف من آخر بايت وصل (308 + Range)،
|
|
@@ -49,12 +97,14 @@ colorTo: gray
|
|
| 49 |
| `YOUTUBE_REFRESH_TOKEN` | توكن تحديث بصلاحية `youtube.upload` (أو `youtube`) |
|
| 50 |
| `TIKTOK_SESSIONID` | اختياري — للبثوث المقيّدة عمريًا (18+) فقط |
|
| 51 |
|
|
|
|
|
|
|
| 52 |
## حدّان من يوتيوب يجب معرفتهما قبل التشغيل
|
| 53 |
|
| 54 |
1. **الكوتا:** `videos.insert` = **1600 وحدة**، والحصة الافتراضية 10,000/يوم ⇒ **~6
|
| 55 |
-
مقاطع يوميًا**
|
| 56 |
-
الملف ينتظر في الطابور حتى منتصف ليل المحيط الهادئ. رفع الحد يحتاج
|
| 57 |
-
(نموذج التدقيق المجاني)، ولا يمكن شراؤه.
|
| 58 |
2. **التدقيق:** كل فيديو يُرفع عبر API من مشروع **غير مُدقَّق** أُنشئ بعد 2020-07-28
|
| 59 |
**يُقفل على الوضع الخاص** مهما أرسلنا `privacyStatus=public`، والطلب لا يفشل.
|
| 60 |
لذلك تعرض اللوحة **الخصوصية الفعلية** بعد القراءة من يوتيوب، وتحاول النزول إلى
|
|
@@ -63,6 +113,7 @@ colorTo: gray
|
|
| 63 |
## المتغيّرات القابلة للتعديل
|
| 64 |
|
| 65 |
كل القيم أدناه موجودة في `Dockerfile` كـ`ENV`، وأي قيمة في Settings تتجاوزها.
|
|
|
|
| 66 |
|
| 67 |
| المتغيّر | الافتراضي | المعنى |
|
| 68 |
|---|---|---|
|
|
@@ -78,6 +129,9 @@ colorTo: gray
|
|
| 78 |
| `ARCHIVE_VERIFY_ROUNDS` / `ARCHIVE_VERIFY_WAIT_SECONDS` | `10` / `60` | صبر التحقق قبل الحذف |
|
| 79 |
| `ARCHIVE_CATEGORY_ID` | `22` | تصنيف يوتيوب (People & Blogs) |
|
| 80 |
| `ARCHIVE_MADE_FOR_KIDS` | `off` | إقرار «مخصص للأطفال» |
|
|
|
|
|
|
|
|
|
|
| 81 |
| `YOUTUBE_DAILY_BUDGET` | `9000` | سقف محلي للكوتا يحمي من 403 |
|
| 82 |
| `MAX_ACCOUNTS` | `0` | عدد المذيعين — صفر = بلا حد |
|
| 83 |
| `CHECK_INTERVAL` | `60` | ثواني بين فحوص حالة البث |
|
|
@@ -89,5 +143,7 @@ colorTo: gray
|
|
| 89 |
PASSWORD=admin DATA_DIR=./storage uvicorn app:app --port 7860
|
| 90 |
```
|
| 91 |
|
| 92 |
-
الاختبارات:
|
| 93 |
-
|
|
|
|
|
|
|
|
|
| 6 |
colorTo: gray
|
| 7 |
---
|
| 8 |
|
| 9 |
+
# مرصد البثوث — تسجيل أصلي + أرشفة تلقائية إلى يوتيوب (V11)
|
| 10 |
|
| 11 |
يراقب حسابات تيك توك على مدار الساعة، ويسجّل كل بث **بجودته الأصلية بلا إعادة ترميز
|
| 12 |
(copy)**، ثم **يرفع التسجيل إلى يوتيوب تلقائيًا ويحذفه من التخزين بعد تأكيد الرفع** —
|
|
|
|
| 20 |
```
|
| 21 |
|
| 22 |
لا إعادة ترميز في أي خطوة: الصورة التي تصل يوتيوب هي بايتات تيك توك نفسها.
|
| 23 |
+
الاستثناء الوحيد هو التسجيل الذي يغيّر فيه تيك توك مقاس الصورة وسط البث (الجولات
|
| 24 |
+
الرباعية) — وهو مشروح أدناه.
|
| 25 |
|
| 26 |
+
## ما الجديد في V11
|
| 27 |
|
| 28 |
+
### ١) إلغاء البثّ المباشر
|
| 29 |
+
|
| 30 |
+
حُذف البثّ المباشر من المشروع نهائيًا، ومعه الملفات:
|
| 31 |
+
|
| 32 |
+
| المحذوف | كان يفعل |
|
| 33 |
|---|---|
|
| 34 |
+
| `youtube_manager.py` | إنشاء وإدارة أحداث `liveBroadcast` ومزامنتها |
|
| 35 |
+
| `relay.py` | دفع البث إلى RTMPS عبر ffmpeg |
|
| 36 |
+
| `flv_bridge.py` | جسر FLV ورصد تغيّر الصيغة أثناء الدفع |
|
| 37 |
+
| `test_concurrency.py` · `verify.sh` | اختبارات وفحوص خاصة بالبث المباشر |
|
| 38 |
+
| لوحة «مدير أحداث يوتيوب» | واجهة التفعيل والأحداث الجارية |
|
| 39 |
+
| `/api/youtube/start` · `/stop` · `/settings` | مسارات التحكم بالبث المباشر |
|
| 40 |
+
|
| 41 |
+
ما بقي من يوتيوب هو `youtube_core.py`: تفويض OAuth، وميزانية الكوتا، وقراءة القناة —
|
| 42 |
+
وكلها تخدم **الأرشفة** وحدها. التطبيق الآن: **مراقبة ← تسجيل ← رفع التسجيل**.
|
| 43 |
+
لم يُمسّ شيء من التسجيل ولا من الأرشفة، وسجل الكوتا بقي كما هو حتى لا يعود العدّاد
|
| 44 |
+
اليومي إلى الصفر فيصطدم بحصة يوتيوب الحقيقية.
|
| 45 |
+
|
| 46 |
+
> إن كانت هناك أحداث بث قديمة أنشأتها النسخة السابقة فالتطبيق يُنبّه عليها في السجل
|
| 47 |
+
> عند الإقلاع، لكنه لم يعد يديرها — أنهِها يدويًا من استوديو يوتيوب.
|
| 48 |
+
|
| 49 |
+
### ٢) إصلاح الصورة المشوّهة في الجولات الرباعية
|
| 50 |
+
|
| 51 |
+
**التشخيص المقيس:** عند دخول المذيع جولة متعددة المذيعين يبدّل تيك توك تخطيط البث
|
| 52 |
+
ويرسل **إعداد صورة جديدًا (SPS/مقاس مختلف) وسط نفس الاتصال**، ثم يعيد الإعداد الأول
|
| 53 |
+
عند الخروج من الجولة. وُجد سببان متتاليان للتشويه:
|
| 54 |
+
|
| 55 |
+
1. المسجّل كان يقارن كل وسم إعداد ببصمة **أول** إعداد في الملف، فيُسقط وسم العودة
|
| 56 |
+
بعد الجولة باعتباره «مكررًا» — بينما الإعداد الفعّال وقتها هو إعداد الجولة. النتيجة:
|
| 57 |
+
يبقى المفكّك على SPS الجولة فتخرج بقية البث مشوّهة. صار التتبّع الآن للإعداد
|
| 58 |
+
**الفعّال الآن**، ولا يُسقط إلا ما يطابقه فعلًا.
|
| 59 |
+
2. حتى بعد حفظ الوسمين، `mkvmerge` لا يحمل إلا إعدادًا واحدًا للمسار (CodecPrivate
|
| 60 |
+
واحد) فيفقد الثاني. على ملف اختبار فيه تغيّر واحد: **صفر خطأ فك ترميز في FLV
|
| 61 |
+
الخام مقابل 150 خطأ في ناتج mkvmerge**. وحتى لو نجا، لا MP4 ولا MKV يحمل مقاسين
|
| 62 |
+
في ترويسة مسار واحد — فيُمطّ أحدهما أو يتكسّر.
|
| 63 |
+
|
| 64 |
+
**العلاج:** عند رصد أكثر من إعداد صورة داخل التسجيل نتجاوز `mkvmerge` ونبني MP4
|
| 65 |
+
**مباشرة من FLV الخام** على إطار ثابت = **المقاس الغالب في البث**، مع
|
| 66 |
+
`scale(force_original_aspect_ratio=decrease) + pad + setsar=1`، فيُحفظ كل مشهد بنسبته
|
| 67 |
+
الصحيحة داخل هوامش سوداء بدل أن يُمطّ. الصوت يُنسخ كما هو بلا إعادة ترميز.
|
| 68 |
+
|
| 69 |
+
| الحالة | المسار | إعادة ترميز؟ |
|
| 70 |
+
|---|---|---|
|
| 71 |
+
| بث عادي (مقاس واحد) — الغالبية | `mkvmerge` ثم `-c copy` كما كان حرفيًا | لا |
|
| 72 |
+
| بث فيه جولات (أكثر من إعداد صورة) | بناء مباشر من FLV بإطار موحّد | الفيديو فقط، لهذا الملف وحده |
|
| 73 |
+
| فشل التوحيد لأي سبب | رجوع تلقائي إلى المسار القديم | لا — ولا يضيع التسجيل |
|
| 74 |
+
|
| 75 |
+
التحكم عبر `GEOMETRY_SCAN` (`auto` افتراضيًا · `on` · `off`) و`GEOMETRY_FIX`
|
| 76 |
+
و`GEOMETRY_PRESET` و`GEOMETRY_CRF` — التفاصيل في `VARIABLES.md`.
|
| 77 |
+
الاختبار: `python test_geometry.py` — ٧ اختبارات على ملفات فيديو حقيقية تُثبت: حفظ
|
| 78 |
+
وسم العودة بعد الجولة، إسقاط المكرر الحقيقي، خروج المقاسين بإطار واحد بلا خطأ فك
|
| 79 |
+
ترميز واحد مع هوامش تحفظ النسبة، وبقاء التسجيل العادي **نسخة مطابقة بلا إعادة ترميز**.
|
| 80 |
+
|
| 81 |
+
## ضمانات الأرشفة (كما هي)
|
| 82 |
|
|
|
|
| 83 |
- **لا حذف قبل التأكيد:** الحذف لا يقع إلا بعد أن يردّ `videos.list` بـ`uploadStatus`
|
| 84 |
يساوي `uploaded` أو `processed`. رفض يوتيوب ⇒ الملف يبقى والسبب يظهر في اللوحة.
|
| 85 |
- **لا رفع مزدوج:** جلسة الرفع تُحفظ؛ الانقطاع يُستأنف من آخر بايت وصل (308 + Range)،
|
|
|
|
| 97 |
| `YOUTUBE_REFRESH_TOKEN` | توكن تحديث بصلاحية `youtube.upload` (أو `youtube`) |
|
| 98 |
| `TIKTOK_SESSIONID` | اختياري — للبثوث المقيّدة عمريًا (18+) فقط |
|
| 99 |
|
| 100 |
+
> أسرار البث المباشر `YOUTUBE_RTMPS_URL` و`YOUTUBE_STREAM_KEY` لم تعد تُقرأ ويمكن حذفها.
|
| 101 |
+
|
| 102 |
## حدّان من يوتيوب يجب معرفتهما قبل التشغيل
|
| 103 |
|
| 104 |
1. **الكوتا:** `videos.insert` = **1600 وحدة**، والحصة الافتراضية 10,000/يوم ⇒ **~6
|
| 105 |
+
مقاطع يوميًا**. بعد إلغاء البث المباشر صارت الحصة كلها للأرشفة. عند النفاد **لا
|
| 106 |
+
يُحذف شيء**: الملف ينتظر في الطابور حتى منتصف ليل المحيط الهادئ. رفع الحد يحتاج
|
| 107 |
+
طلب تمديد (نموذج التدقيق المجاني)، ولا يمكن شراؤه.
|
| 108 |
2. **التدقيق:** كل فيديو يُرفع عبر API من مشروع **غير مُدقَّق** أُنشئ بعد 2020-07-28
|
| 109 |
**يُقفل على الوضع الخاص** مهما أرسلنا `privacyStatus=public`، والطلب لا يفشل.
|
| 110 |
لذلك تعرض اللوحة **الخصوصية الفعلية** بعد القراءة من يوتيوب، وتحاول النزول إلى
|
|
|
|
| 113 |
## المتغيّرات القابلة للتعديل
|
| 114 |
|
| 115 |
كل القيم أدناه موجودة في `Dockerfile` كـ`ENV`، وأي قيمة في Settings تتجاوزها.
|
| 116 |
+
القائمة الكاملة في `VARIABLES.md`.
|
| 117 |
|
| 118 |
| المتغيّر | الافتراضي | المعنى |
|
| 119 |
|---|---|---|
|
|
|
|
| 129 |
| `ARCHIVE_VERIFY_ROUNDS` / `ARCHIVE_VERIFY_WAIT_SECONDS` | `10` / `60` | صبر التحقق قبل الحذف |
|
| 130 |
| `ARCHIVE_CATEGORY_ID` | `22` | تصنيف يوتيوب (People & Blogs) |
|
| 131 |
| `ARCHIVE_MADE_FOR_KIDS` | `off` | إقرار «مخصص للأطفال» |
|
| 132 |
+
| `GEOMETRY_SCAN` | `auto` | متى نُوحّد الإطار بعد الجولات (`auto`/`on`/`off`) |
|
| 133 |
+
| `GEOMETRY_FIX` | `normalize` | `normalize` توحيد الإطار · `off` تعطيل |
|
| 134 |
+
| `GEOMETRY_PRESET` / `GEOMETRY_CRF` | `veryfast` / `20` | جودة التوحيد عند اللزوم |
|
| 135 |
| `YOUTUBE_DAILY_BUDGET` | `9000` | سقف محلي للكوتا يحمي من 403 |
|
| 136 |
| `MAX_ACCOUNTS` | `0` | عدد المذيعين — صفر = بلا حد |
|
| 137 |
| `CHECK_INTERVAL` | `60` | ثواني بين فحوص حالة البث |
|
|
|
|
| 143 |
PASSWORD=admin DATA_DIR=./storage uvicorn app:app --port 7860
|
| 144 |
```
|
| 145 |
|
| 146 |
+
الاختبارات:
|
| 147 |
+
- `python test_archive.py` — 19 اخ��بارًا لوحدة الأرشفة مقابل خادم يحاكي بروتوكول
|
| 148 |
+
Google: الاستئناف، نفاد الكوتا، قفل الخصوصية، الرفض، الحماية من التنظيف.
|
| 149 |
+
- `python test_geometry.py` — 7 اختبارات لإصلاح الجولات الرباعية على ملفات حقيقية.
|
VARIABLES.md
CHANGED
|
@@ -75,31 +75,56 @@
|
|
| 75 |
|
| 76 |
---
|
| 77 |
|
| 78 |
-
## ٤.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
| المتغيّر | الافتراضي | المعنى |
|
| 81 |
|---|---|---|
|
| 82 |
-
| `
|
| 83 |
-
| `
|
| 84 |
-
| `
|
| 85 |
-
| `
|
| 86 |
-
| `
|
| 87 |
-
| `
|
| 88 |
-
| `
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
| `TIKTOK_TT_TARGET_IDC` | `useast1a` | مركز بيانات تيك توك المستهدف للكوكيز. |
|
| 99 |
|
| 100 |
---
|
| 101 |
|
| 102 |
-
##
|
|
|
|
| 103 |
|
| 104 |
1. **`videos.insert` = 1600 وحدة** والحصة الافتراضية 10,000/يوم ⇒ **~6 مقاطع يوميًا**.
|
| 105 |
عند النفاد يبقى الملف في الطابور حتى منتصف ليل توقيت المحيط الهادئ. التمديد يكون
|
|
|
|
| 75 |
|
| 76 |
---
|
| 77 |
|
| 78 |
+
## ٤. إصلاح الجولات الرباعية — توحيد إطار الصورة
|
| 79 |
+
|
| 80 |
+
عند دخول المذيع جولة متعددة المذيعين يبدّل تيك توك تخطيط البث فيتغيّر إعداد الصورة
|
| 81 |
+
(SPS/المقاس) وسط نفس الاتصال، ثم يعود عند الخروج منها. ملف FLV الخام يحتمل ذلك،
|
| 82 |
+
لكن `mkvmerge` لا يحمل إلا إعدادًا واحدًا للمسار فتُفكّ إطارات الجولة بإعداد خاطئ —
|
| 83 |
+
وهذه هي الصورة المشوّهة. القياس على ملف اختبار فيه تغيّر واحد: **150 خطأ فك ترميز**
|
| 84 |
+
في ناتج `mkvmerge` مقابل **صفر** في FLV نفسه.
|
| 85 |
+
|
| 86 |
+
العلاج: عند رصد أكثر من إعداد صورة داخل التسجيل يُبنى MP4 **مباشرة من FLV** على إطار
|
| 87 |
+
ثابت = المقاس الغالب، مع `scale(decrease)+pad+setsar=1` فتُحفظ نسبة كل مشهد بهوامش
|
| 88 |
+
سوداء بدل التمطيط. التسجيل الذي لا يتغيّر فيه المقاس — وهو الغالب — يمرّ بالمسار
|
| 89 |
+
القديم نفسه: `mkvmerge` ثم `-c copy` **بلا إعادة ترميز**.
|
| 90 |
|
| 91 |
| المتغيّر | الافتراضي | المعنى |
|
| 92 |
|---|---|---|
|
| 93 |
+
| `GEOMETRY_SCAN` | `auto` | `auto` = نُوحّد الإطار فقط حين يرصد المسجّل تغيّر إعداد الصورة (بلا أي كلفة على التسجيلات العادية) · `on` = نفحص كل تسجيل بـ`ffprobe` على الإطارات المفتاحية أيضًا (يلتقط حتى ملفات الاسترداد) · `off` = تعطيل الإصلاح والعودة للسلوك القديم حرفيًا. |
|
| 94 |
+
| `GEOMETRY_FIX` | `normalize` | `normalize` = توحيد الإطار · `off` = لا معالجة. |
|
| 95 |
+
| `GEOMETRY_PRESET` | `veryfast` | إعداد libx264 عند التوحيد (أسرع = أخف على المعالج). |
|
| 96 |
+
| `GEOMETRY_CRF` | `20` | جودة التوحيد. الأصغر أجود وأثقل. |
|
| 97 |
+
| `GEOMETRY_PAD_COLOR` | `black` | لون الهوامش. |
|
| 98 |
+
| `GEOMETRY_SCAN_TIMEOUT` | `1200` | مهلة فحص المقاسات بالثواني. |
|
| 99 |
+
| `GEOMETRY_ENCODE_TIMEOUT` | `21600` | مهلة إعادة الترميز بالثواني (6 ساعات). |
|
| 100 |
+
|
| 101 |
+
> التوحيد **لا يقع إلا على التسجيل المصاب**. وإن فشل لأي سبب يعود التجهيز تلقائيًا
|
| 102 |
+
> إلى المسار القديم بلا إعادة ترميز، فلا يضيع التسجيل أبدًا.
|
| 103 |
+
> الاختبار: `python test_geometry.py` (٧ اختبارات على ملفات حقيقية).
|
| 104 |
+
|
| 105 |
+
---
|
| 106 |
+
|
| 107 |
+
## ٥. متغيّرات البث المباشر — **حُذفت**
|
| 108 |
+
|
| 109 |
+
أُلغي البثّ المباشر من المشروع، وحُذفت معه الملفات `youtube_manager.py` و`relay.py`
|
| 110 |
+
و`flv_bridge.py`. المتغيّرات التالية لم تعد مقروءة في أي مكان، ووجودها في Settings
|
| 111 |
+
لا يضر ولا يفعل شيئًا، ويمكن حذفها:
|
| 112 |
+
|
| 113 |
+
`YOUTUBE_VIDEO_MODE` · `YOUTUBE_SEND_MODE` · `YOUTUBE_MAX_EVENTS` · `YOUTUBE_SCAN_SECONDS`
|
| 114 |
+
· `YOUTUBE_END_GRACE` · `YOUTUBE_FORMAT_GUARD` · `YOUTUBE_FLAP_LIMIT` ·
|
| 115 |
+
`YOUTUBE_FLAP_FALLBACK_MODE` · `YOUTUBE_FIFO_TIMESHIFT` · `YOUTUBE_FIFO_QUEUE` ·
|
| 116 |
+
`YOUTUBE_HEALTH_INTERVAL` · `YOUTUBE_X264_PRESET` · `YOUTUBE_CRF` ·
|
| 117 |
+
`YOUTUBE_RECOVERY_WAIT` · `YOUTUBE_START_TIMEOUT`
|
| 118 |
+
|
| 119 |
+
| المتغيّر الباقي | الافتراضي | المعنى |
|
| 120 |
+
|---|---|---|
|
| 121 |
+
| `YOUTUBE_DAILY_BUDGET` | `9000` | سقف محلي لوحدات كوتا يوتيوب يوميًا. صار كله للأرشفة بعد إلغاء البث المباشر. |
|
| 122 |
| `TIKTOK_TT_TARGET_IDC` | `useast1a` | مركز بيانات تيك توك المستهدف للكوكيز. |
|
| 123 |
|
| 124 |
---
|
| 125 |
|
| 126 |
+
## ٦. حدود يوتيوب التي لا يصلحها أي متغيّر
|
| 127 |
+
|
| 128 |
|
| 129 |
1. **`videos.insert` = 1600 وحدة** والحصة الافتراضية 10,000/يوم ⇒ **~6 مقاطع يوميًا**.
|
| 130 |
عند النفاد يبقى الملف في الطابور حتى منتصف ليل توقيت المحيط الهادئ. التمديد يكون
|
app.py
CHANGED
|
@@ -263,13 +263,13 @@ def rank_sources(source):
|
|
| 263 |
if item['url'] not in seen:result.append(item);seen.add(item['url'])
|
| 264 |
return result
|
| 265 |
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
|
|
|
| 270 |
from youtube_archive import Archive
|
| 271 |
-
# الأرشفة: ترفع كل تسجيل مكتمل إلى يوتيوب ثم تحرر التخزين الدائم.
|
| 272 |
-
# نفس أسرار OAuth ونفس ميزانية الكوتا المحلية، فلا يسرق أحدهما حصة الآخر.
|
| 273 |
archive=Archive(FILES,youtube,log,ROOT/'uploads.json')
|
| 274 |
PROTECT_QUEUE=os.getenv('ARCHIVE_PROTECT_QUEUE','on').strip().lower() in ('1','on','true','yes')
|
| 275 |
_live_meta={}
|
|
@@ -303,6 +303,20 @@ def _flv_audio_signature(body):
|
|
| 303 |
return None
|
| 304 |
|
| 305 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
class FLVWriter:
|
| 307 |
"""Write complete FLV tags only; arbitrary network chunk boundaries are not packet boundaries.
|
| 308 |
|
|
@@ -315,6 +329,15 @@ class FLVWriter:
|
|
| 315 |
mkvmerge/ffmpeg مدةً خاطئة ويُنتج ملفًا مكسورًا.
|
| 316 |
وإن أعلن المصدر الجديد إعداد فيديو مختلفًا (تغيّر المقاس/الكوديك) يُرفض اللصق ويُنهى
|
| 317 |
الملف كما كان يحدث سابقًا — ملفان سليمان خير من ملف واحد معطوب.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
"""
|
| 319 |
GAP_MS=40
|
| 320 |
def __init__(self,out):
|
|
@@ -323,10 +346,12 @@ class FLVWriter:
|
|
| 323 |
self.ts_offset=0 # إزاحة تُضاف لأختام المصدر الحالي
|
| 324 |
self.last_ts=0 # آخر ختم كُتب فعلًا
|
| 325 |
self.src_base=None # أول ختم في المصدر الحالي
|
| 326 |
-
self.video_cfg=None # بصمة إعداد الفيديو لل
|
| 327 |
-
self.audio_cfg=None # بصمة إعداد الصوت لل
|
| 328 |
self.check_cfg=False # يُفحص عند أول وسم إعداد بعد اللصق فقط
|
| 329 |
self.sources=1
|
|
|
|
|
|
|
| 330 |
def reconnect(self):
|
| 331 |
self.buf.clear()
|
| 332 |
self.header=False
|
|
@@ -349,26 +374,37 @@ class FLVWriter:
|
|
| 349 |
while len(self.buf)>=11:
|
| 350 |
size=11+int.from_bytes(self.buf[1:4],'big')+4
|
| 351 |
if len(self.buf)<size:break
|
| 352 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
tag=bytearray(self.buf[:size])
|
| 354 |
-
kind=tag[0]&31
|
| 355 |
-
if kind==18:
|
| 356 |
-
# onMetaData من مصدر لاحق: تكرار لا لزوم له، ويقرؤه ffmpeg/mkvmerge
|
| 357 |
-
# كمسار بيانات إضافي عند نقطة اللصق. يُسقط بصمت.
|
| 358 |
-
del self.buf[:size];continue
|
| 359 |
-
if kind in (8,9):
|
| 360 |
-
body=bytes(tag[11:size-4])
|
| 361 |
-
sig=_flv_video_signature(body) if kind==9 else _flv_audio_signature(body)
|
| 362 |
-
if sig is not None:
|
| 363 |
-
known=self.video_cfg if kind==9 else self.audio_cfg
|
| 364 |
-
if known is not None and sig==known:
|
| 365 |
-
# إعداد مطابق لما كُتب في رأس الملف. إعادة إرساله وسط الملف
|
| 366 |
-
# تجعل ffmpeg ينشئ مسارًا جديدًا بعد كتابة ترويسة MKV،
|
| 367 |
-
# فيخرج ملف Matroska معطوب («Duplicate element»). يُسقط.
|
| 368 |
-
del self.buf[:size];continue
|
| 369 |
-
if self.check_cfg and kind==9 and known is not None:
|
| 370 |
-
raise SourceConfigChanged('إعداد فيديو مختلف في المصدر الجديد')
|
| 371 |
-
if kind==9 and sig is not None:self.check_cfg=False
|
| 372 |
ts=(tag[4]<<16)|(tag[5]<<8)|tag[6]|(tag[7]<<24)
|
| 373 |
if self.src_base is None:self.src_base=ts
|
| 374 |
new=ts-self.src_base+self.ts_offset
|
|
@@ -378,13 +414,6 @@ class FLVWriter:
|
|
| 378 |
self.out.write(tag)
|
| 379 |
else:
|
| 380 |
tag=self.buf[:size]
|
| 381 |
-
kind=tag[0]&31
|
| 382 |
-
if kind==9 and self.video_cfg is None:
|
| 383 |
-
sig=_flv_video_signature(bytes(tag[11:size-4]))
|
| 384 |
-
if sig is not None:self.video_cfg=sig
|
| 385 |
-
elif kind==8 and self.audio_cfg is None:
|
| 386 |
-
sig=_flv_audio_signature(bytes(tag[11:size-4]))
|
| 387 |
-
if sig is not None:self.audio_cfg=sig
|
| 388 |
ts=(tag[4]<<16)|(tag[5]<<8)|tag[6]|(tag[7]<<24)
|
| 389 |
if ts>self.last_ts:self.last_ts=ts
|
| 390 |
self.out.write(tag)
|
|
@@ -398,7 +427,98 @@ def inspect_media(path):
|
|
| 398 |
if not video:raise RuntimeError('لم يوجد مسار فيديو صالح')
|
| 399 |
return video
|
| 400 |
|
| 401 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
mkv=FILES/(stem+'.partial.mkv');mp4=FILES/(stem+'.partial.mp4')
|
| 403 |
# SMART: ensure space before heavy remux (needs ~2x file size temporarily)
|
| 404 |
smart_cleanup(reason="قبل البناء")
|
|
@@ -407,22 +527,30 @@ def finalize(raw,stem,user,meta=None):
|
|
| 407 |
# Double-check free after waiting for lock
|
| 408 |
if shutil.disk_usage(FILES).free < ENSURE_FREE_MB*1024*1024:
|
| 409 |
smart_cleanup(reason="قبل البناء - طارئ")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
r=subprocess.run([MKVMERGE,'-o',str(mkv),str(raw)],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=3600)
|
| 411 |
if r.returncode not in (0,1) or not mkv.exists():raise RuntimeError('تعذر تغليف FLV إلى MKV؛ احتفظنا بالأصل')
|
| 412 |
info=inspect_media(mkv)
|
| 413 |
codec_args=['-tag:v','hvc1'] if info.get('codec_name')=='hevc' else []
|
| 414 |
r=subprocess.run([FFMPEG,'-v','error','-y','-i',str(mkv),'-map','0:v:0','-map','0:a:0?','-c','copy',*codec_args,str(mp4)],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=3600)
|
| 415 |
if r.returncode==0 and mp4.exists() and mp4.stat().st_size>20000:
|
| 416 |
-
|
| 417 |
-
mp4.replace(FILES/(stem+'.mp4'));mkv.unlink(missing_ok=True);raw.unlink(missing_ok=True)
|
| 418 |
-
quality=f"{measured['width']}×{measured['height']} · {measured.get('codec_name','')}"
|
| 419 |
-
if user in tasks:update(user,quality=quality)
|
| 420 |
-
log(user,'تم حفظ MP4 دون إعادة ترميز؛ قياس الملف: '+quality+' (ليس فحص فك ترميز كاملًا)')
|
| 421 |
-
try:archive.enqueue(FILES/(stem+'.mp4'),user,{**(meta or {}),'quality':quality})
|
| 422 |
-
except Exception:log(user,'تعذر إدراج التسجيل في طابور الأرشفة؛ الملف محفوظ على التخزين')
|
| 423 |
# SMART: never keep mkv if mp4 succeeded (user request)
|
| 424 |
-
|
| 425 |
-
smart_cleanup(reason="بعد البناء")
|
| 426 |
else:
|
| 427 |
mp4.unlink(missing_ok=True)
|
| 428 |
# If mp4 failed but mkv succeeded, keep mkv but user said no need mkv for youtube. Keep mkv only as fallback
|
|
@@ -494,6 +622,7 @@ def record(user,candidate,stop):
|
|
| 494 |
log(user,f'أُعيد وصل البث داخل نفس الملف (مصدر {writer.sources} · {fresh["label"]})')
|
| 495 |
out.flush()
|
| 496 |
if writer.sources>1:log(user,f'الملف يضم {writer.sources} مصادر ملصوقة — رفعة واحدة بدل {writer.sources}')
|
|
|
|
| 497 |
finally:
|
| 498 |
try:response.close()
|
| 499 |
except Exception:pass
|
|
@@ -501,7 +630,8 @@ def record(user,candidate,stop):
|
|
| 501 |
raw.unlink(missing_ok=True);raise RuntimeError('لم تصل بيانات فيديو كافية')
|
| 502 |
if user in tasks:update(user,status='تجهيز الملف — قد يستغرق وقتًا للبث الطويل')
|
| 503 |
meta={**_live_meta.get(user,{}),'duration':int(time.monotonic()-start)}
|
| 504 |
-
|
|
|
|
| 505 |
except Exception:
|
| 506 |
# Keep the complete-tag raw recording for manual recovery, never silently delete it.
|
| 507 |
if raw.exists():raw.replace(FILES/(stem+'.recovery.flv'))
|
|
@@ -556,13 +686,8 @@ class TokenBody(BaseModel):
|
|
| 556 |
token:str=''
|
| 557 |
@app.get('/',response_class=HTMLResponse)
|
| 558 |
def index():return Path(__file__).with_name('index.html').read_text()
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
class YouTubeSettings(BaseModel):
|
| 562 |
-
name:str=''
|
| 563 |
-
privacy:str
|
| 564 |
-
title:str
|
| 565 |
-
description:str
|
| 566 |
@app.get('/api/youtube')
|
| 567 |
def youtube_state():return youtube.snapshot()
|
| 568 |
@app.post('/api/youtube/check')
|
|
@@ -572,22 +697,6 @@ def youtube_check():
|
|
| 572 |
from youtube_api import APIError
|
| 573 |
msg=str(e) if isinstance(e,(APIError,RuntimeError,ValueError)) else 'تعذر فحص تفويض يوتيوب'
|
| 574 |
raise HTTPException(400,msg)
|
| 575 |
-
@app.post('/api/youtube/settings')
|
| 576 |
-
def youtube_settings(a:YouTubeSettings):
|
| 577 |
-
try:youtube.settings(a.model_dump())
|
| 578 |
-
except ValueError as e:raise HTTPException(400,str(e))
|
| 579 |
-
return {'ok':True}
|
| 580 |
-
@app.post('/api/youtube/start')
|
| 581 |
-
def youtube_start(a:YouTubeStart):
|
| 582 |
-
try:youtube.enable(a.channel_id)
|
| 583 |
-
except Exception as e:
|
| 584 |
-
from youtube_api import APIError
|
| 585 |
-
msg=str(e) if isinstance(e,(APIError,RuntimeError,ValueError)) else 'تعذر بدء أتمتة يوتيوب'
|
| 586 |
-
raise HTTPException(400,msg)
|
| 587 |
-
return {'ok':True}
|
| 588 |
-
@app.post('/api/youtube/stop')
|
| 589 |
-
def youtube_stop():
|
| 590 |
-
youtube.disable();return {'ok':True}
|
| 591 |
|
| 592 |
class ArchiveSettings(BaseModel):
|
| 593 |
privacy:str
|
|
@@ -622,7 +731,7 @@ def state():
|
|
| 622 |
task_list=[{'username':u,**t,'has_token':bool(get_token(u))} for u,t in tasks.items()]
|
| 623 |
tiktok_state=tiktok_auth.describe([t['username'] for t in task_list])
|
| 624 |
free_mb = round(disk.free/1048576,1)
|
| 625 |
-
return {'tasks':task_list,'logs':events[-60:],'files':files,'version':'
|
| 626 |
@app.post('/api/accounts')
|
| 627 |
def add(a:Account):
|
| 628 |
try:u=clean_user(a.username)
|
|
@@ -747,7 +856,10 @@ def startup():
|
|
| 747 |
if enabled:start(u)
|
| 748 |
try:save() # تثبيت القائمة المدموجة على القرص لهذه الجلسة
|
| 749 |
except OSError:pass
|
| 750 |
-
|
|
|
|
|
|
|
|
|
|
| 751 |
try:
|
| 752 |
archive.start()
|
| 753 |
except Exception:
|
|
|
|
| 263 |
if item['url'] not in seen:result.append(item);seen.add(item['url'])
|
| 264 |
return result
|
| 265 |
|
| 266 |
+
# البث المباشر أُلغي من المشروع (V11): لا مدير أحداث، ولا دفع RTMPS، ولا إنشاء
|
| 267 |
+
# liveBroadcast/liveStream. ما بقي من يوتيوب هو تفويض OAuth وميزانية الكوتا والقناة،
|
| 268 |
+
# وكلها تخدم الأرشفة وحدها.
|
| 269 |
+
from youtube_core import YouTubeCore
|
| 270 |
+
youtube=YouTubeCore(log,ROOT/'youtube-api-state.json')
|
| 271 |
from youtube_archive import Archive
|
| 272 |
+
# الأرشفة: ترفع كل تسجيل مكتمل إلى يوتيوب ثم تحرر التخزين الدائم.
|
|
|
|
| 273 |
archive=Archive(FILES,youtube,log,ROOT/'uploads.json')
|
| 274 |
PROTECT_QUEUE=os.getenv('ARCHIVE_PROTECT_QUEUE','on').strip().lower() in ('1','on','true','yes')
|
| 275 |
_live_meta={}
|
|
|
|
| 303 |
return None
|
| 304 |
|
| 305 |
|
| 306 |
+
def _looks_like_config(kind,first,second):
|
| 307 |
+
"""فحص رخيص ببايتين: هل هذا وسم إعداد (Sequence Header)؟
|
| 308 |
+
|
| 309 |
+
يُستدعى لكل وسم صوت/فيديو، لذلك لا يجوز أن ينسخ الحمولة كاملة. النسخ الكامل
|
| 310 |
+
لا يقع إلا بعد أن يجتاز الوسم هذا الفحص، وهي حالة نادرة (وسم إعداد واحد عند
|
| 311 |
+
بداية كل مقطع أو عند تغيّر المقاس وسط البث)."""
|
| 312 |
+
if kind==9:
|
| 313 |
+
if first&0x80:return (first&0x0F)==0 # Enhanced RTMP: PacketTypeSequenceStart
|
| 314 |
+
return (first&15) in (7,12) and second==0 # AVC/HEVC sequence header
|
| 315 |
+
if kind==8:
|
| 316 |
+
return (first>>4)==10 and second==0 # AAC sequence header
|
| 317 |
+
return False
|
| 318 |
+
|
| 319 |
+
|
| 320 |
class FLVWriter:
|
| 321 |
"""Write complete FLV tags only; arbitrary network chunk boundaries are not packet boundaries.
|
| 322 |
|
|
|
|
| 329 |
mkvmerge/ffmpeg مدةً خاطئة ويُنتج ملفًا مكسورًا.
|
| 330 |
وإن أعلن المصدر الجديد إعداد فيديو مختلفًا (تغيّر المقاس/الكوديك) يُرفض اللصق ويُنهى
|
| 331 |
الملف كما كان يحدث سابقًا — ملفان سليمان خير من ملف واحد معطوب.
|
| 332 |
+
|
| 333 |
+
إصلاح الجولات الرباعية (V11): حين يدخل المذيع جولة متعددة المذيعين يبدّل تيك توك
|
| 334 |
+
تخطيط الصورة ويرسل **وسم إعداد فيديو جديدًا وسط نفس الاتصال** (مقاس/SPS مختلف)،
|
| 335 |
+
ثم يرسل الإعداد الأول مرة أخرى عند الخروج من الجولة. الكود السابق كان يقارن كل
|
| 336 |
+
وسم إعداد ببصمة **أول** مصدر، فيُسقط وسم العودة باعتباره «مكررًا» بينما الإعداد
|
| 337 |
+
الفعّال وقتها هو إعداد الجولة — فيبقى المفكّك على SPS الجولة ويخرج ما بقي من البث
|
| 338 |
+
صورةً مشوّهة/مكسّرة. صار التتبّع الآن للإعداد **الفعّال الآن**، ولا يُسقط إلا ما
|
| 339 |
+
يطابقه فعلًا، وتُحصى كل تغيّرة في `video_changes` ليعرف التجهيز أن الملف يحمل
|
| 340 |
+
أكثر من إعداد صورة فيُوحّد الإطار بدل أن يُسلّمه لـmkvmerge الذي يحتفظ بأول إعداد فقط.
|
| 341 |
"""
|
| 342 |
GAP_MS=40
|
| 343 |
def __init__(self,out):
|
|
|
|
| 346 |
self.ts_offset=0 # إزاحة تُضاف لأختام المصدر الحالي
|
| 347 |
self.last_ts=0 # آخر ختم كُتب فعلًا
|
| 348 |
self.src_base=None # أول ختم في المصدر الحالي
|
| 349 |
+
self.video_cfg=None # بصمة إعداد الفيديو الفعّال الآن
|
| 350 |
+
self.audio_cfg=None # بصمة إعداد الصوت الفعّال الآن
|
| 351 |
self.check_cfg=False # يُفحص عند أول وسم إعداد بعد اللصق فقط
|
| 352 |
self.sources=1
|
| 353 |
+
self.video_changes=0 # كم مرة تغيّر إعداد الصورة داخل الملف (جولات/تبديل تخطيط)
|
| 354 |
+
self.audio_changes=0
|
| 355 |
def reconnect(self):
|
| 356 |
self.buf.clear()
|
| 357 |
self.header=False
|
|
|
|
| 374 |
while len(self.buf)>=11:
|
| 375 |
size=11+int.from_bytes(self.buf[1:4],'big')+4
|
| 376 |
if len(self.buf)<size:break
|
| 377 |
+
kind=self.buf[0]&31
|
| 378 |
+
if kind==18 and (self.ts_offset or self.check_cfg):
|
| 379 |
+
# onMetaData من مصدر لاحق: تكرار لا لزوم له، ويقرؤه ffmpeg/mkvmerge
|
| 380 |
+
# كمسار بيانات إضافي عند نقطة اللصق. يُسقط بصمت.
|
| 381 |
+
del self.buf[:size];continue
|
| 382 |
+
# --- متابعة إعداد الصورة/الصوت الفعّال (يشمل تغيّره وسط نفس المصدر) ---
|
| 383 |
+
if kind in (8,9) and size>=13 and _looks_like_config(kind,self.buf[11],self.buf[12]):
|
| 384 |
+
body=bytes(self.buf[11:size-4])
|
| 385 |
+
sig=_flv_video_signature(body) if kind==9 else _flv_audio_signature(body)
|
| 386 |
+
if sig is not None:
|
| 387 |
+
active=self.video_cfg if kind==9 else self.audio_cfg
|
| 388 |
+
if active is not None and sig==active:
|
| 389 |
+
# مطابق للإعداد الفعّال الآن: إعادة إرساله وسط الملف تجعل
|
| 390 |
+
# ffmpeg/mkvmerge ينشئ مسارًا جديدًا بعد كتابة الترويسة
|
| 391 |
+
# («Duplicate element»). يُسقط بلا خسارة — المفكّك يملكه أصلًا.
|
| 392 |
+
# ومطابقته تُثبت أن المصدر الجديد مطابق، فينتهي فحص اللصق.
|
| 393 |
+
if kind==9:self.check_cfg=False
|
| 394 |
+
del self.buf[:size];continue
|
| 395 |
+
if self.check_cfg and kind==9 and active is not None:
|
| 396 |
+
# إعداد مختلف في **مصدر جديد** بعد انقطاع: لا نلصقه في نفس الملف.
|
| 397 |
+
raise SourceConfigChanged('إعداد فيديو مختلف في المصدر الجديد')
|
| 398 |
+
# إعداد جديد مختلف داخل نفس المصدر: يُكتب ويصير هو الفعّال.
|
| 399 |
+
# هذا هو حال الجولات الرباعية؛ إسقاطه أو إسقاط العودة عنه = صورة مشوّهة.
|
| 400 |
+
if kind==9:
|
| 401 |
+
if active is not None:self.video_changes+=1
|
| 402 |
+
self.video_cfg=sig;self.check_cfg=False
|
| 403 |
+
else:
|
| 404 |
+
if active is not None:self.audio_changes+=1
|
| 405 |
+
self.audio_cfg=sig
|
| 406 |
+
if self.ts_offset:
|
| 407 |
tag=bytearray(self.buf[:size])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
ts=(tag[4]<<16)|(tag[5]<<8)|tag[6]|(tag[7]<<24)
|
| 409 |
if self.src_base is None:self.src_base=ts
|
| 410 |
new=ts-self.src_base+self.ts_offset
|
|
|
|
| 414 |
self.out.write(tag)
|
| 415 |
else:
|
| 416 |
tag=self.buf[:size]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
ts=(tag[4]<<16)|(tag[5]<<8)|tag[6]|(tag[7]<<24)
|
| 418 |
if ts>self.last_ts:self.last_ts=ts
|
| 419 |
self.out.write(tag)
|
|
|
|
| 427 |
if not video:raise RuntimeError('لم يوجد مسار فيديو صالح')
|
| 428 |
return video
|
| 429 |
|
| 430 |
+
# ===== الجولات الرباعية: توحيد الإطار عند تغيّر المقاس وسط البث ==================
|
| 431 |
+
# القياس الذي بُني عليه هذا الإصلاح:
|
| 432 |
+
# • حين يدخل المذيع جولة متعددة المذيعين يبدّل تيك توك تخطيط البث ويرسل إعداد صورة
|
| 433 |
+
# جديدًا (مقاس/SPS مختلف) وسط نفس الاتصال، ثم يعيد الأول عند الخروج من الجولة.
|
| 434 |
+
# • ملف FLV الخام يحتمل ذلك ويفكّه ffmpeg بلا خطأ واحد.
|
| 435 |
+
# • لكن mkvmerge يحتفظ بأول إعداد فقط (CodecPrivate واحد للمسار)، فكل إطارات الجولة
|
| 436 |
+
# تُفكّ بـSPS خاطئ: قياسًا على ملف اختبار فيه تغيّر واحد، نتج 150 خطأ فك ترميز في
|
| 437 |
+
# MKV مقابل صفر في FLV نفسه. هذه هي «الصورة المشوّهة» التي تظهر في التسجيل.
|
| 438 |
+
# • MP4 كذلك لا يحمل إلا مقاسًا واحدًا في ترويسة المسار، فأي مقاس آخر يُمطّ أو يتكسّر.
|
| 439 |
+
# الحل: عند رصد أكثر من إعداد صورة داخل التسجيل نتجاوز mkvmerge ونبني MP4 مباشرة من
|
| 440 |
+
# FLV الخام على إطار ثابت = المقاس الغالب في البث، مع الحفاظ على نسبة كل مشهد
|
| 441 |
+
# (scale ... decrease + pad) و setsar=1، فلا تمطّط ولا تكسّر. الصوت يُنسخ كما هو.
|
| 442 |
+
# التسجيلات التي لا يتغيّر فيها المقاس — وهي الغالبية — تمرّ بالمسار القديم نفسه
|
| 443 |
+
# (mkvmerge ثم -c copy) بلا إعادة ترميز ولا أي تغيير في السلوك.
|
| 444 |
+
GEOMETRY_SCAN=os.getenv('GEOMETRY_SCAN','auto').strip().lower() # auto | on | off
|
| 445 |
+
GEOMETRY_FIX=os.getenv('GEOMETRY_FIX','normalize').strip().lower() # normalize | off
|
| 446 |
+
GEOMETRY_PRESET=os.getenv('GEOMETRY_PRESET','veryfast').strip() or 'veryfast'
|
| 447 |
+
GEOMETRY_CRF=str(os.getenv('GEOMETRY_CRF','20')).strip() or '20'
|
| 448 |
+
GEOMETRY_PAD_COLOR=os.getenv('GEOMETRY_PAD_COLOR','black').strip() or 'black'
|
| 449 |
+
GEOMETRY_SCAN_TIMEOUT=max(60,int(os.getenv('GEOMETRY_SCAN_TIMEOUT','1200') or 1200))
|
| 450 |
+
GEOMETRY_ENCODE_TIMEOUT=max(600,int(os.getenv('GEOMETRY_ENCODE_TIMEOUT','21600') or 21600))
|
| 451 |
+
|
| 452 |
+
def scan_geometry(path):
|
| 453 |
+
"""مقاسات الإطارات المفتاحية داخل الملف، الأكثر تكرارًا أولًا: [((w,h),count),...].
|
| 454 |
+
|
| 455 |
+
`-skip_frame nokey` يجعل المفكّك يتخطى كل ما ليس إطارًا مفتاحيًا، فالكلفة قراءة
|
| 456 |
+
الملف وفكّ إطار كل بضع ثوانٍ فقط — لا فكّ ترميز كامل."""
|
| 457 |
+
r=subprocess.run([FFPROBE,'-v','error','-select_streams','v:0','-skip_frame','nokey',
|
| 458 |
+
'-show_entries','frame=width,height','-of','csv=p=0',str(path)],
|
| 459 |
+
capture_output=True,text=True,timeout=GEOMETRY_SCAN_TIMEOUT)
|
| 460 |
+
counts={}
|
| 461 |
+
for line in r.stdout.splitlines():
|
| 462 |
+
nums=re.findall(r'\d+',line)
|
| 463 |
+
if len(nums)<2:continue
|
| 464 |
+
w,h=int(nums[0]),int(nums[1])
|
| 465 |
+
if w<16 or h<16:continue
|
| 466 |
+
counts[(w,h)]=counts.get((w,h),0)+1
|
| 467 |
+
return sorted(counts.items(),key=lambda kv:(-kv[1],-(kv[0][0]*kv[0][1])))
|
| 468 |
+
|
| 469 |
+
def even(n):
|
| 470 |
+
n=int(n)
|
| 471 |
+
return n-1 if n%2 else n
|
| 472 |
+
|
| 473 |
+
def normalize_mixed(raw,out,canvas):
|
| 474 |
+
"""بناء MP4 بإطار ثابت من FLV الخام مباشرة (بلا mkvmerge الذي يفقد الإعداد الثاني)."""
|
| 475 |
+
w,h=even(canvas[0]),even(canvas[1])
|
| 476 |
+
if w<16 or h<16:return False
|
| 477 |
+
vf=('scale=%d:%d:force_original_aspect_ratio=decrease:flags=bicubic,'
|
| 478 |
+
'pad=%d:%d:(ow-iw)/2:(oh-ih)/2:color=%s,setsar=1,format=yuv420p')%(w,h,w,h,GEOMETRY_PAD_COLOR)
|
| 479 |
+
base=[FFMPEG,'-v','error','-y','-i',str(raw),'-map','0:v:0','-map','0:a:0?','-vf',vf,
|
| 480 |
+
'-c:v','libx264','-preset',GEOMETRY_PRESET,'-crf',GEOMETRY_CRF,'-profile:v','high','-pix_fmt','yuv420p']
|
| 481 |
+
# نسخ الصوت أولًا (بلا إعادة ترميز)؛ وإن رفض المُغلِّف — كتغيّر إعداد AAC وسط البث —
|
| 482 |
+
# نعيد ترميز الصوت وحده بدل خسارة التسجيل كله.
|
| 483 |
+
for audio in (['-c:a','copy'],['-c:a','aac','-b:a','160k']):
|
| 484 |
+
out.unlink(missing_ok=True)
|
| 485 |
+
r=subprocess.run(base+audio+['-movflags','+faststart',str(out)],
|
| 486 |
+
stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=GEOMETRY_ENCODE_TIMEOUT)
|
| 487 |
+
if r.returncode==0 and out.exists() and out.stat().st_size>20000:return True
|
| 488 |
+
out.unlink(missing_ok=True)
|
| 489 |
+
return False
|
| 490 |
+
|
| 491 |
+
def plan_geometry(raw,user,cfg_changes):
|
| 492 |
+
"""هل يحتاج هذا التسجيل توحيد إطار؟ وعلى أي مقاس؟ يرجّع (canvas, reason) أو (None,'')."""
|
| 493 |
+
if GEOMETRY_FIX!='normalize' or GEOMETRY_SCAN=='off':return None,''
|
| 494 |
+
if GEOMETRY_SCAN=='auto' and not cfg_changes:return None,''
|
| 495 |
+
try:found=scan_geometry(raw)
|
| 496 |
+
except Exception:
|
| 497 |
+
found=[]
|
| 498 |
+
log(user,'تعذّر فحص مقاسات الصورة؛ نكمل بالمسار المعتاد')
|
| 499 |
+
if not cfg_changes and len(found)<2:return None,''
|
| 500 |
+
if len(found)>1:
|
| 501 |
+
shapes=' · '.join('%d×%d'%(w,h) for (w,h),_ in found[:4])
|
| 502 |
+
return found[0][0],'مقاسات داخل البث: '+shapes
|
| 503 |
+
if cfg_changes:
|
| 504 |
+
if found:return found[0][0],'تغيّر إعداد الصورة %d مرة بنفس المقاس'%cfg_changes
|
| 505 |
+
try:info=inspect_media(raw)
|
| 506 |
+
except Exception:return None,''
|
| 507 |
+
return (info.get('width',0),info.get('height',0)),'تغيّر إعداد الصورة %d مرة'%cfg_changes
|
| 508 |
+
return None,''
|
| 509 |
+
|
| 510 |
+
def publish(mp4,stem,user,meta,note):
|
| 511 |
+
"""خطوة مشتركة بين المسارين: قياس الملف، تثبيت اسمه، إدراجه في طابور الأرشفة."""
|
| 512 |
+
measured=inspect_media(mp4)
|
| 513 |
+
mp4.replace(FILES/(stem+'.mp4'))
|
| 514 |
+
quality=f"{measured['width']}×{measured['height']} · {measured.get('codec_name','')}"
|
| 515 |
+
if user in tasks:update(user,quality=quality)
|
| 516 |
+
log(user,note+quality+' (ليس فحص فك ترميز كاملًا)')
|
| 517 |
+
try:archive.enqueue(FILES/(stem+'.mp4'),user,{**(meta or {}),'quality':quality})
|
| 518 |
+
except Exception:log(user,'تعذر إدراج التسجيل في طابور الأرشفة؛ الملف محفوظ على التخزين')
|
| 519 |
+
smart_cleanup(reason="بعد البناء")
|
| 520 |
+
|
| 521 |
+
def finalize(raw,stem,user,meta=None,cfg_changes=0):
|
| 522 |
mkv=FILES/(stem+'.partial.mkv');mp4=FILES/(stem+'.partial.mp4')
|
| 523 |
# SMART: ensure space before heavy remux (needs ~2x file size temporarily)
|
| 524 |
smart_cleanup(reason="قبل البناء")
|
|
|
|
| 527 |
# Double-check free after waiting for lock
|
| 528 |
if shutil.disk_usage(FILES).free < ENSURE_FREE_MB*1024*1024:
|
| 529 |
smart_cleanup(reason="قبل البناء - طارئ")
|
| 530 |
+
# --- مسار الجولات: التسجيل يحمل أكثر من إعداد صورة ---------------------
|
| 531 |
+
canvas,reason=plan_geometry(raw,user,cfg_changes)
|
| 532 |
+
if canvas:
|
| 533 |
+
if user in tasks:update(user,status='توحيد إطار الصورة بعد الجولات')
|
| 534 |
+
log(user,'رُصد تغيّر في صورة البث (%s)؛ نبني الملف على إطار ثابت %d×%d مع حفظ نسبة كل مشهد — mkvmerge لا يحفظ إلا إعدادًا واحدًا فتخرج الصورة مشوّهة'%(reason,even(canvas[0]),even(canvas[1])))
|
| 535 |
+
try:ok=normalize_mixed(raw,mp4,canvas)
|
| 536 |
+
except subprocess.TimeoutExpired:
|
| 537 |
+
ok=False;mp4.unlink(missing_ok=True);log(user,'تجاوز توحيد الإطار المهلة')
|
| 538 |
+
except Exception:
|
| 539 |
+
ok=False;mp4.unlink(missing_ok=True)
|
| 540 |
+
if ok:
|
| 541 |
+
raw.unlink(missing_ok=True);mkv.unlink(missing_ok=True)
|
| 542 |
+
publish(mp4,stem,user,meta,'تم حفظ MP4 بإطار موحّد بعد الجولات؛ قياس الملف: ')
|
| 543 |
+
return
|
| 544 |
+
log(user,'تعذّر توحيد الإطار؛ نكمل بالمسار المعتاد بلا إعادة ترميز')
|
| 545 |
r=subprocess.run([MKVMERGE,'-o',str(mkv),str(raw)],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=3600)
|
| 546 |
if r.returncode not in (0,1) or not mkv.exists():raise RuntimeError('تعذر تغليف FLV إلى MKV؛ احتفظنا بالأصل')
|
| 547 |
info=inspect_media(mkv)
|
| 548 |
codec_args=['-tag:v','hvc1'] if info.get('codec_name')=='hevc' else []
|
| 549 |
r=subprocess.run([FFMPEG,'-v','error','-y','-i',str(mkv),'-map','0:v:0','-map','0:a:0?','-c','copy',*codec_args,str(mp4)],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=3600)
|
| 550 |
if r.returncode==0 and mp4.exists() and mp4.stat().st_size>20000:
|
| 551 |
+
mkv.unlink(missing_ok=True);raw.unlink(missing_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 552 |
# SMART: never keep mkv if mp4 succeeded (user request)
|
| 553 |
+
publish(mp4,stem,user,meta,'تم حفظ MP4 دون إعادة ترميز؛ قياس الملف: ')
|
|
|
|
| 554 |
else:
|
| 555 |
mp4.unlink(missing_ok=True)
|
| 556 |
# If mp4 failed but mkv succeeded, keep mkv but user said no need mkv for youtube. Keep mkv only as fallback
|
|
|
|
| 622 |
log(user,f'أُعيد وصل البث داخل نفس الملف (مصدر {writer.sources} · {fresh["label"]})')
|
| 623 |
out.flush()
|
| 624 |
if writer.sources>1:log(user,f'الملف يضم {writer.sources} مصادر ملصوقة — رفعة واحدة بدل {writer.sources}')
|
| 625 |
+
if writer.video_changes:log(user,f'تغيّر إعداد صورة البث {writer.video_changes} مرة أثناء التسجيل (جولات/تبديل تخطيط) — سيُوحَّد الإطار عند التجهيز')
|
| 626 |
finally:
|
| 627 |
try:response.close()
|
| 628 |
except Exception:pass
|
|
|
|
| 630 |
raw.unlink(missing_ok=True);raise RuntimeError('لم تصل بيانات فيديو كافية')
|
| 631 |
if user in tasks:update(user,status='تجهيز الملف — قد يستغرق وقتًا للبث الطويل')
|
| 632 |
meta={**_live_meta.get(user,{}),'duration':int(time.monotonic()-start)}
|
| 633 |
+
changes=getattr(writer,'video_changes',0)
|
| 634 |
+
try:finalize(raw,stem,user,meta,changes)
|
| 635 |
except Exception:
|
| 636 |
# Keep the complete-tag raw recording for manual recovery, never silently delete it.
|
| 637 |
if raw.exists():raw.replace(FILES/(stem+'.recovery.flv'))
|
|
|
|
| 686 |
token:str=''
|
| 687 |
@app.get('/',response_class=HTMLResponse)
|
| 688 |
def index():return Path(__file__).with_name('index.html').read_text()
|
| 689 |
+
# مسارات يوتيوب: فحص التفويض والقناة فقط. مسارات البث المباشر
|
| 690 |
+
# (/api/youtube/start و/stop و/settings) حُذفت مع إلغاء البث المباشر.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 691 |
@app.get('/api/youtube')
|
| 692 |
def youtube_state():return youtube.snapshot()
|
| 693 |
@app.post('/api/youtube/check')
|
|
|
|
| 697 |
from youtube_api import APIError
|
| 698 |
msg=str(e) if isinstance(e,(APIError,RuntimeError,ValueError)) else 'تعذر فحص تفويض يوتيوب'
|
| 699 |
raise HTTPException(400,msg)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 700 |
|
| 701 |
class ArchiveSettings(BaseModel):
|
| 702 |
privacy:str
|
|
|
|
| 731 |
task_list=[{'username':u,**t,'has_token':bool(get_token(u))} for u,t in tasks.items()]
|
| 732 |
tiktok_state=tiktok_auth.describe([t['username'] for t in task_list])
|
| 733 |
free_mb = round(disk.free/1048576,1)
|
| 734 |
+
return {'tasks':task_list,'logs':events[-60:],'files':files,'version':'V11-archive-no-live','free_gb':round(disk.free/1073741824,2),'free_mb':free_mb,'max_accounts':MAX_ACCOUNTS if MAX_ACCOUNTS is not None else 'مفتوح','tiktok':tiktok_state, 'keep_last':KEEP_LAST_FILES, 'ensure_free_mb':ENSURE_FREE_MB, 'archive':archive.snapshot()}
|
| 735 |
@app.post('/api/accounts')
|
| 736 |
def add(a:Account):
|
| 737 |
try:u=clean_user(a.username)
|
|
|
|
| 856 |
if enabled:start(u)
|
| 857 |
try:save() # تثبيت القائمة المدموجة على القرص لهذه الجلسة
|
| 858 |
except OSError:pass
|
| 859 |
+
try:
|
| 860 |
+
youtube.restore()
|
| 861 |
+
except Exception:
|
| 862 |
+
log('system','تعذر قراءة سجل يوتيوب عند الإقلاع؛ الأرشفة تكمل عملها')
|
| 863 |
try:
|
| 864 |
archive.start()
|
| 865 |
except Exception:
|
deploy_hode_arch.py
CHANGED
|
@@ -32,7 +32,10 @@ VARIABLES = {
|
|
| 32 |
'MIN_FREE_MB': '1024',
|
| 33 |
'ENSURE_FREE_MB': '2048',
|
| 34 |
'KEEP_LAST_FILES': '2',
|
| 35 |
-
'
|
|
|
|
|
|
|
|
|
|
| 36 |
}
|
| 37 |
|
| 38 |
|
|
@@ -55,7 +58,7 @@ def main(space, token):
|
|
| 55 |
print('أُنشئ Space جديد:', space)
|
| 56 |
api.upload_folder(folder_path=str(SRC), repo_id=space, repo_type='space',
|
| 57 |
ignore_patterns=['__pycache__/*', 'storage/*', '*.pyc'],
|
| 58 |
-
commit_message='
|
| 59 |
print('رُفعت الملفات.')
|
| 60 |
for key, value in VARIABLES.items():
|
| 61 |
try:
|
|
|
|
| 32 |
'MIN_FREE_MB': '1024',
|
| 33 |
'ENSURE_FREE_MB': '2048',
|
| 34 |
'KEEP_LAST_FILES': '2',
|
| 35 |
+
'GEOMETRY_SCAN': 'auto',
|
| 36 |
+
'GEOMETRY_FIX': 'normalize',
|
| 37 |
+
'GEOMETRY_PRESET': 'veryfast',
|
| 38 |
+
'GEOMETRY_CRF': '20',
|
| 39 |
}
|
| 40 |
|
| 41 |
|
|
|
|
| 58 |
print('أُنشئ Space جديد:', space)
|
| 59 |
api.upload_folder(folder_path=str(SRC), repo_id=space, repo_type='space',
|
| 60 |
ignore_patterns=['__pycache__/*', 'storage/*', '*.pyc'],
|
| 61 |
+
commit_message='V11: إلغاء البث المباشر + إصلاح تشويه الجولات الرباعية')
|
| 62 |
print('رُفعت الملفات.')
|
| 63 |
for key, value in VARIABLES.items():
|
| 64 |
try:
|
flv_bridge.py
DELETED
|
@@ -1,82 +0,0 @@
|
|
| 1 |
-
"""Streaming legacy HEVC FLV (codec-id 12) -> Enhanced FLV hvc1.
|
| 2 |
-
Only tag headers/container fields are changed. Encoded video payload, CTS and DTS
|
| 3 |
-
are preserved. H.264 and already-enhanced FLV tags pass through unchanged.
|
| 4 |
-
No whole-recording buffering, decoding, rescaling or frame-rate conversion.
|
| 5 |
-
|
| 6 |
-
Extra guard for YouTube ingestion:
|
| 7 |
-
FormatChanged is raised the moment a *different* video decoder configuration
|
| 8 |
-
record (AVCDecoderConfigurationRecord / HEVCDecoderConfigurationRecord, i.e.
|
| 9 |
-
SPS/PPS or VPS/SPS/PPS) shows up in the middle of a session. That is exactly
|
| 10 |
-
what a phone does when the layout changes (solo <-> PK round) or when the
|
| 11 |
-
encoder restarts after the app was backgrounded and resumed.
|
| 12 |
-
|
| 13 |
-
Why it matters: `ffmpeg -c:v copy` cannot detect an input parameter change, so
|
| 14 |
-
it silently forwards the new frames while the FLV it already sent to YouTube
|
| 15 |
-
declares the old width/height in onMetaData and in the first sequence header.
|
| 16 |
-
YouTube then stretches/re-locks the picture -> fast zoom in-out, stutter,
|
| 17 |
-
a long "repeated clip", freeze, black flash. The caller restarts the sender on
|
| 18 |
-
this exception; the fresh RTMPS session re-declares the new size cleanly
|
| 19 |
-
(a ~1-3 s gap instead of minutes of broken video).
|
| 20 |
-
|
| 21 |
-
This is pure byte inspection of tag headers: no decoding, no re-encoding, no
|
| 22 |
-
rescaling, so the original picture quality and the original source are untouched.
|
| 23 |
-
"""
|
| 24 |
-
class FormatChanged(Exception):
|
| 25 |
-
"""Source video decoder configuration changed mid-stream."""
|
| 26 |
-
|
| 27 |
-
def _video_signature(body):
|
| 28 |
-
"""Fingerprint of a video sequence header, or None if `body` is not one."""
|
| 29 |
-
if not body:return None
|
| 30 |
-
if body[0]&0x80: # Enhanced RTMP
|
| 31 |
-
if body[0]&0x0F:return None # PacketType 0 == sequence header
|
| 32 |
-
return bytes(body[1:5])+body[5:] # FourCC + decoder config record
|
| 33 |
-
if (body[0]&15)==7 and len(body)>1 and body[1]==0: # legacy AVC sequence header
|
| 34 |
-
return b'avc1'+body[5:]
|
| 35 |
-
return None
|
| 36 |
-
|
| 37 |
-
class FLVBridge:
|
| 38 |
-
def __init__(self,detect_format_change=True):
|
| 39 |
-
self.buffer=bytearray();self.header_done=False;self.converted_tags=0
|
| 40 |
-
self.detect_format_change=detect_format_change
|
| 41 |
-
self.video_cfg=None;self.format_changes=0
|
| 42 |
-
def feed(self,chunk):
|
| 43 |
-
self.buffer.extend(chunk);output=bytearray()
|
| 44 |
-
if not self.header_done:
|
| 45 |
-
if len(self.buffer)<9:return b''
|
| 46 |
-
if self.buffer[:3]!=b'FLV':raise ValueError('Source is not FLV')
|
| 47 |
-
header_size=int.from_bytes(self.buffer[5:9],'big')+4
|
| 48 |
-
if not 13<=header_size<=1048576:raise ValueError('Invalid FLV header size')
|
| 49 |
-
if len(self.buffer)<header_size:return b''
|
| 50 |
-
output.extend(self.buffer[:header_size]);del self.buffer[:header_size];self.header_done=True
|
| 51 |
-
offset=0
|
| 52 |
-
while len(self.buffer)-offset>=11:
|
| 53 |
-
header=bytearray(self.buffer[offset:offset+11]);size=int.from_bytes(header[1:4],'big');total=11+size+4
|
| 54 |
-
if len(self.buffer)-offset<total:break
|
| 55 |
-
body=bytes(self.buffer[offset+11:offset+11+size]);tag_type=header[0]
|
| 56 |
-
if tag_type & 0x20:raise ValueError('Filtered/encrypted FLV tag is unsupported')
|
| 57 |
-
if (tag_type & 31)==9 and body and not (body[0]&0x80) and (body[0]&15)==12:
|
| 58 |
-
if len(body)<5:raise ValueError('Truncated HEVC FLV video header')
|
| 59 |
-
frame=(body[0]>>4)&7;packet=body[1]
|
| 60 |
-
if packet==0: # HEVC configuration record, no composition time in SequenceStart
|
| 61 |
-
body=bytes([0x80|(frame<<4)])+b'hvc1'+body[5:]
|
| 62 |
-
elif packet==1: # CodedFrames includes the same signed 24-bit composition offset
|
| 63 |
-
body=bytes([0x80|(frame<<4)|1])+b'hvc1'+body[2:]
|
| 64 |
-
elif packet==2:
|
| 65 |
-
body=bytes([0x80|(frame<<4)|2])+b'hvc1'+body[5:]
|
| 66 |
-
else:raise ValueError('Unsupported legacy HEVC packet type')
|
| 67 |
-
self.converted_tags+=1
|
| 68 |
-
if (tag_type & 31)==9:
|
| 69 |
-
signature=_video_signature(body)
|
| 70 |
-
if signature is not None:
|
| 71 |
-
if self.video_cfg is None:self.video_cfg=signature
|
| 72 |
-
elif signature!=self.video_cfg:
|
| 73 |
-
self.format_changes+=1
|
| 74 |
-
if self.detect_format_change:
|
| 75 |
-
raise FormatChanged('video decoder configuration changed mid-stream')
|
| 76 |
-
self.video_cfg=signature
|
| 77 |
-
header[1:4]=len(body).to_bytes(3,'big')
|
| 78 |
-
output.extend(header);output.extend(body);output.extend((11+len(body)).to_bytes(4,'big'))
|
| 79 |
-
offset+=total
|
| 80 |
-
if offset:del self.buffer[:offset]
|
| 81 |
-
if len(self.buffer)>17*1024*1024:raise ValueError('FLV packet buffer exceeds limit')
|
| 82 |
-
return bytes(output)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
index.html
CHANGED
|
@@ -28,20 +28,11 @@ textarea,select{font:inherit;background:#0c1220;color:#e4eaf7;border:1px solid #
|
|
| 28 |
<p class="notice"><b>مهم:</b> جلسات تيك توك قصيرة العمر. فحصنا على ملف كوكيز حقيقي أظهر <code dir="ltr">sid_guard</code> بعمر <b>6 ساعات</b> فقط، وبعدها يرفض تيك توك التوكن حتى لو كان "صحيحًا". لذلك الصق <b>كامل سطر Cookie</b> لا القيمة وحدها — حتى يرى التطبيق <code dir="ltr">sid_guard</code> ويعرض لك موعد الانتهاء ويحذّرك قبله. الحفظ من هذه اللوحة <b>لا يحتاج Factory reboot</b>.</p>
|
| 29 |
<p class="notice" style="border-right-color:#e1b470;background:#2b2414"><b>🚨 أمان:</b> لا تصدّر كوكيز المتصفح كاملة أبدًا. أداة التصدير تُخرج كل المواقع بما فيها <b>جوجل ويوتيوب</b> (<code dir="ltr">SID, HSID, APISID, SAPISID, __Secure-1PSID</code>…) وهذه تعادل كلمة مرورك. التطبيق يحتاج تيك توك فقط — انسخ <code dir="ltr">sessionid</code> وحدها، أو احذف كل سطر لا ينتهي بـ <code dir="ltr">tiktok.com</code>.</p>
|
| 30 |
</section>
|
| 31 |
-
<section class="panel"><h2>
|
| 32 |
-
<p>
|
| 33 |
-
<div class="row"><button id="ytcheck" class="secondary">
|
| 34 |
<p id="channel">لم يتم فحص القناة بعد.</p><p id="ytstate">تحميل الحالة…</p>
|
| 35 |
-
<div id="events" class="cards"></div>
|
| 36 |
<small id="budget"></small>
|
| 37 |
-
<p class="notice">التفعيل يسمح للتطبيق بإنشاء أحداث متزامنة (حتى الحد الأقصى المحدد)، وبدئها وإنهائها، وحذف أحداثه الفارغة فقط. لا ينظف أحداث النسخ القديمة. إعداد التفعيل محفوظ ويُستأنف بعد إعادة التشغيل. لا ضمان تشغيل 24/7. كل حدث إضافي يستهلك معالجًا وإنترنت؛ راقب أداء الاستضافة.</p>
|
| 38 |
-
<details><summary>قوالب العنوان والوصف — تُطبّق على الأحداث الجديدة</summary>
|
| 39 |
-
<form id="settingsform" style="display:block"><div class="settings">
|
| 40 |
-
<div><label for="privacy">خصوصية الأحداث</label><select id="privacy"><option value="public">عام</option><option value="unlisted">غير مُدرج</option><option value="private">خاص</option></select></div>
|
| 41 |
-
<div class="wide"><label for="titletemplate">قالب العنوان</label><input id="titletemplate" required></div>
|
| 42 |
-
<div class="wide"><label for="desctemplate">قالب الوصف</label><textarea id="desctemplate" rows="6"></textarea></div></div>
|
| 43 |
-
<p>الاسم والعنوان والهاشتاكات تُجلب تلقائيًا من حساب تيك توك المبثوث عند بدء الجلسة: <code dir="ltr">{name}</code> اسم المذيع من حسابه، <code dir="ltr">{title}</code> عنوان بثه على تيك توك، <code dir="ltr">{hashtags}</code> هاشتاكات بثه. متغيرات إضافية: <code dir="ltr">{username} {date} {time} {room_id}</code>. يُضاف رابط حساب المذيع نفسه تلقائيًا لكل حدث. العنوان الناتج حتى 100 حرف. تعديل القوالب يتطلب تعطيل الإدارة وانتظار إغلاق الأحداث.</p>
|
| 44 |
-
<button type="submit" id="savesettings">حفظ إعدادات الأحداث</button></form></details>
|
| 45 |
</section>
|
| 46 |
<section class="panel"><h2>الأرشفة التلقائية إلى يوتيوب</h2>
|
| 47 |
<small>كل تسجيل مكتمل يُرفع إلى القناة بنسخة copy كما هي، ثم يُحذف من التخزين بعد أن يؤكد يوتيوب استلامه. المراقبة والتسجيل لا يتوقفان أثناء الرفع، والملف لا يُحذف أبدًا قبل التأكيد.</small>
|
|
@@ -62,22 +53,16 @@ textarea,select{font:inherit;background:#0c1220;color:#e4eaf7;border:1px solid #
|
|
| 62 |
<footer>أسرار OAuth وتوكنات الجلسات تبقى في Secrets ومجلد البيانات فقط، ولا تظهر في الواجهة. راقب حصة التخزين من Hugging Face. تقدير API محلي وليس قراءة لحصة Google الفعلية. استخدمه للمحتوى الذي تملك حق إعادة نشره.</footer>
|
| 63 |
</main><script>
|
| 64 |
const $=id=>document.getElementById(id),esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
| 65 |
-
let
|
| 66 |
function error(text){$('error').textContent=text;$('error').hidden=!text}
|
| 67 |
async function api(url,method='GET',body){const r=await fetch(url,{method,cache:'no-store',headers:body?{'Content-Type':'application/json'}:{},body:body?JSON.stringify(body):undefined});let j;try{j=await r.json()}catch{}if(!r.ok)throw Error(typeof j?.detail==='string'?j.detail:'تعذر تنفيذ الطلب');return j}
|
| 68 |
-
function fillSettings(s){$('privacy').value=s.privacy||'public';$('titletemplate').value=s.title||'';$('desctemplate').value=s.description||'';settingsLoaded=true}
|
| 69 |
async function refresh(){if(polling)return;polling=true;try{
|
| 70 |
const [y,s]=await Promise.all([api('/api/youtube'),api('/api/state')]);lastY=y;
|
| 71 |
-
|
| 72 |
-
$('capacity').textContent=`${s.version} • حد الحسابات: ${s.max_accounts} • أقصى أحداث يوتيوب متزامنة: ${y.max_events||3}`;
|
| 73 |
$('channel').textContent=y.channel?`القناة المرتبطة: ${y.channel.title} · ${y.channel.id}`:'اضغط فحص OAuth للتأكد من القناة قبل النشر.';
|
| 74 |
channelId=y.channel?.id||'';
|
| 75 |
-
$('ytstate').textContent=
|
| 76 |
-
$('
|
| 77 |
-
$('budget').textContent=`استهلاك API محلي تقديري: ${y.budget?.estimated_units||0} وحدة • جلسات في سجل التطبيق: ${y.history_count||0} • ${y.configured?'أسرار OAuth موجودة (الفحص يؤكد صلاحيتها)':'أسرار OAuth غير مكتملة'}`;
|
| 78 |
-
$('ytstart').disabled=!!y.automatic;$('ytstop').disabled=!y.automatic&&!evs.length;
|
| 79 |
-
$('savesettings').disabled=!!y.automatic||!!evs.length;
|
| 80 |
-
if(!settingsLoaded&&y.settings)fillSettings(y.settings);
|
| 81 |
$('tasks').innerHTML=s.tasks.length?s.tasks.map(t=>`<article class="card"><h3>@${esc(t.username)}</h3><p>${esc(t.status)}</p><small>${esc(t.quality||'الجودة لم تحدد بعد')} · ${t.received_mb||0} MB · ${Math.floor((t.elapsed_seconds||0)/60)} دقيقة</small><p>${t.enabled?'المراقبة مفعّلة':'المراقبة معطّلة'} · توكن الجلسة: ${t.has_token?'موجود ✓':'غير مضاف'}</p><div class="row"><button data-user="${esc(t.username)}" data-action="start">تشغيل</button><button class="secondary" data-user="${esc(t.username)}" data-action="stop">إيقاف</button><button class="secondary" data-user="${esc(t.username)}" data-action="token">توكن</button><button class="danger" data-user="${esc(t.username)}" data-action="delete">حذف</button></div></article>`).join(''):'<p>أضف حسابات المذيع المطلوبة فقط.</p>';
|
| 82 |
$('files').innerHTML=s.files.length?s.files.map(f=>`<div class="file"><div><code>${esc(f.name)}</code><br><small>${f.mb} MB</small></div><div class="row"><a href="/api/files/${encodeURIComponent(f.name)}">تنزيل</a><button class="danger" data-file="${esc(f.name)}">حذف الملف</button></div></div>`).join(''):'<p>لا توجد ملفات مكتملة بعد.</p>';
|
| 83 |
$('logs').innerHTML=s.logs.slice().reverse().map(l=>`<div>${esc(l.time)} · @${esc(l.user)} · ${esc(l.message)}</div>`).join('');
|
|
@@ -119,10 +104,7 @@ $('tokcheck').onclick=async()=>{const u=$('tokuser').value.trim();if(!u){tokShow
|
|
| 119 |
try{error('');$('tokcheck').disabled=true;tokShow('… يختبر فتح الحساب عند تيك توك');
|
| 120 |
const r=await api('/api/tiktok/token/check/'+encodeURIComponent(u),'POST');
|
| 121 |
tokShow(`<b>${r.ok===false?'✖ ':'✔ '}${esc(r.message||r.result||'')}</b><br><small dir="ltr">result=${esc(r.result||'')} · anonymous=${r.anonymous?.urls??'—'} urls · authenticated=${r.authenticated?.urls??'—'} urls · token_configured=${r.token_configured}</small>`)}catch(e){tokShow('✖ '+esc(e.message))}finally{$('tokcheck').disabled=false}};
|
| 122 |
-
$('ytcheck').onclick=async()=>{try{error('');$('ytcheck').disabled=true;const j=await api('/api/youtube/check','POST');channelId=j.channel.id;await refresh();alert(`تم تفويض القناة: ${j.channel.title}\n${j.channel.id}\nل
|
| 123 |
-
$('ytstart').onclick=async()=>{try{if(!channelId)throw Error('اضغط فحص OAuth أولًا');if(!confirm(`تفعيل إنشاء وبدء أحداث تلقائية لقناة ${lastY?.channel?.title||channelId}؟\nالخصوصية: ${lastY?.settings?.privacy}\nلكل مذيع مباشر حدث مستقل يبث بنفس الوقت (حتى ${lastY?.max_events||3} أحداث)، واسمه وعنوانه وهاشتاكاته تُجلب من حساب تيك توك نفسه.`))return;error('');await api('/api/youtube/start','POST',{channel_id:channelId});await refresh()}catch(e){error(e.message)}};
|
| 124 |
-
$('ytstop').onclick=async()=>{if(!confirm('تعطيل الإدارة وإنهاء جميع الأحداث الجارية مع الاحتفاظ بالأرشيف؟'))return;try{error('');await api('/api/youtube/stop','POST');await refresh()}catch(e){error(e.message)}};
|
| 125 |
-
$('settingsform').onsubmit=async e=>{e.preventDefault();try{error('');await api('/api/youtube/settings','POST',{name:'',privacy:$('privacy').value,title:$('titletemplate').value,description:$('desctemplate').value});settingsLoaded=false;await refresh()}catch(e){error(e.message)}};
|
| 126 |
$('arctoggle').onclick=async()=>{try{error('');const cur=$('arctoggle').textContent.includes('تعطيل');await api('/api/archive/enable','POST',{enabled:!cur});await refresh()}catch(e){error(e.message)}};
|
| 127 |
$('arcscan').onclick=async()=>{try{error('');const r=await api('/api/archive/scan','POST');await refresh();if(!r.adopted)error('لا توجد ملفات جديدة خارج الطابور')}catch(e){error(e.message)}};
|
| 128 |
$('arcform').onsubmit=async e=>{e.preventDefault();try{error('');await api('/api/archive/settings','POST',{privacy:$('arcprivacy').value,fallback:$('arcfallback').value,title:$('arctitle').value,description:$('arcdesc').value,delete_after_upload:$('arcdelete').checked});arcLoaded=false;await refresh()}catch(e){error(e.message)}};
|
|
|
|
| 28 |
<p class="notice"><b>مهم:</b> جلسات تيك توك قصيرة العمر. فحصنا على ملف كوكيز حقيقي أظهر <code dir="ltr">sid_guard</code> بعمر <b>6 ساعات</b> فقط، وبعدها يرفض تيك توك التوكن حتى لو كان "صحيحًا". لذلك الصق <b>كامل سطر Cookie</b> لا القيمة وحدها — حتى يرى التطبيق <code dir="ltr">sid_guard</code> ويعرض لك موعد الانتهاء ويحذّرك قبله. الحفظ من هذه اللوحة <b>لا يحتاج Factory reboot</b>.</p>
|
| 29 |
<p class="notice" style="border-right-color:#e1b470;background:#2b2414"><b>🚨 أمان:</b> لا تصدّر كوكيز المتصفح كاملة أبدًا. أداة التصدير تُخرج كل المواقع بما فيها <b>جوجل ويوتيوب</b> (<code dir="ltr">SID, HSID, APISID, SAPISID, __Secure-1PSID</code>…) وهذه تعادل كلمة مرورك. التطبيق يحتاج تيك توك فقط — انسخ <code dir="ltr">sessionid</code> وحدها، أو احذف كل سطر لا ينتهي بـ <code dir="ltr">tiktok.com</code>.</p>
|
| 30 |
</section>
|
| 31 |
+
<section class="panel"><h2>ربط قناة يوتيوب</h2>
|
| 32 |
+
<p>البثّ المباشر أُلغي من هذا المشروع: لا يُنشأ أي حدث بث على يوتيوب ولا يُدفع أي فيديو مباشرةً. يبقى الربط لازمًا لغرض واحد فقط هو <b>رفع التسجيلات المكتملة</b> من لوحة الأرشفة أدناه.</p>
|
| 33 |
+
<div class="row"><button id="ytcheck" class="secondary">فحص OAuth والقناة</button></div>
|
| 34 |
<p id="channel">لم يتم فحص القناة بعد.</p><p id="ytstate">تحميل الحالة…</p>
|
|
|
|
| 35 |
<small id="budget"></small>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
</section>
|
| 37 |
<section class="panel"><h2>الأرشفة التلقائية إلى يوتيوب</h2>
|
| 38 |
<small>كل تسجيل مكتمل يُرفع إلى القناة بنسخة copy كما هي، ثم يُحذف من التخزين بعد أن يؤكد يوتيوب استلامه. المراقبة والتسجيل لا يتوقفان أثناء الرفع، والملف لا يُحذف أبدًا قبل التأكيد.</small>
|
|
|
|
| 53 |
<footer>أسرار OAuth وتوكنات الجلسات تبقى في Secrets ومجلد البيانات فقط، ولا تظهر في الواجهة. راقب حصة التخزين من Hugging Face. تقدير API محلي وليس قراءة لحصة Google الفعلية. استخدمه للمحتوى الذي تملك حق إعادة نشره.</footer>
|
| 54 |
</main><script>
|
| 55 |
const $=id=>document.getElementById(id),esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
| 56 |
+
let channelId='',lastY=null,polling=false;
|
| 57 |
function error(text){$('error').textContent=text;$('error').hidden=!text}
|
| 58 |
async function api(url,method='GET',body){const r=await fetch(url,{method,cache:'no-store',headers:body?{'Content-Type':'application/json'}:{},body:body?JSON.stringify(body):undefined});let j;try{j=await r.json()}catch{}if(!r.ok)throw Error(typeof j?.detail==='string'?j.detail:'تعذر تنفيذ الطلب');return j}
|
|
|
|
| 59 |
async function refresh(){if(polling)return;polling=true;try{
|
| 60 |
const [y,s]=await Promise.all([api('/api/youtube'),api('/api/state')]);lastY=y;
|
| 61 |
+
$('capacity').textContent=`${s.version} • حد الحسابات: ${s.max_accounts} • البث المباشر: ملغى (تسجيل وأرشفة فقط)`;
|
|
|
|
| 62 |
$('channel').textContent=y.channel?`القناة المرتبطة: ${y.channel.title} · ${y.channel.id}`:'اضغط فحص OAuth للتأكد من القناة قبل النشر.';
|
| 63 |
channelId=y.channel?.id||'';
|
| 64 |
+
$('ytstate').textContent=y.configured?'أسرار OAuth موجودة — التسجيلات تُرفع إلى هذه القناة بعد اكتمالها.':'أسرار OAuth غير مكتملة — لن يُرفع أي تسجيل.';
|
| 65 |
+
$('budget').textContent=`استهلاك API محلي تقديري اليوم: ${y.budget?.estimated_units||0} وحدة`;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
$('tasks').innerHTML=s.tasks.length?s.tasks.map(t=>`<article class="card"><h3>@${esc(t.username)}</h3><p>${esc(t.status)}</p><small>${esc(t.quality||'الجودة لم تحدد بعد')} · ${t.received_mb||0} MB · ${Math.floor((t.elapsed_seconds||0)/60)} دقيقة</small><p>${t.enabled?'المراقبة مفعّلة':'المراقبة معطّلة'} · توكن الجلسة: ${t.has_token?'موجود ✓':'غير مضاف'}</p><div class="row"><button data-user="${esc(t.username)}" data-action="start">تشغيل</button><button class="secondary" data-user="${esc(t.username)}" data-action="stop">إيقاف</button><button class="secondary" data-user="${esc(t.username)}" data-action="token">توكن</button><button class="danger" data-user="${esc(t.username)}" data-action="delete">حذف</button></div></article>`).join(''):'<p>أضف حسابات المذيع المطلوبة فقط.</p>';
|
| 67 |
$('files').innerHTML=s.files.length?s.files.map(f=>`<div class="file"><div><code>${esc(f.name)}</code><br><small>${f.mb} MB</small></div><div class="row"><a href="/api/files/${encodeURIComponent(f.name)}">تنزيل</a><button class="danger" data-file="${esc(f.name)}">حذف الملف</button></div></div>`).join(''):'<p>لا توجد ملفات مكتملة بعد.</p>';
|
| 68 |
$('logs').innerHTML=s.logs.slice().reverse().map(l=>`<div>${esc(l.time)} · @${esc(l.user)} · ${esc(l.message)}</div>`).join('');
|
|
|
|
| 104 |
try{error('');$('tokcheck').disabled=true;tokShow('… يختبر فتح الحساب عند تيك توك');
|
| 105 |
const r=await api('/api/tiktok/token/check/'+encodeURIComponent(u),'POST');
|
| 106 |
tokShow(`<b>${r.ok===false?'✖ ':'✔ '}${esc(r.message||r.result||'')}</b><br><small dir="ltr">result=${esc(r.result||'')} · anonymous=${r.anonymous?.urls??'—'} urls · authenticated=${r.authenticated?.urls??'—'} urls · token_configured=${r.token_configured}</small>`)}catch(e){tokShow('✖ '+esc(e.message))}finally{$('tokcheck').disabled=false}};
|
| 107 |
+
$('ytcheck').onclick=async()=>{try{error('');$('ytcheck').disabled=true;const j=await api('/api/youtube/check','POST');channelId=j.channel.id;await refresh();alert(`تم تفويض القناة: ${j.channel.title}\n${j.channel.id}\nالفحص للقراءة فقط: لا يُنشأ أي حدث بث، التسجيلات تُرفع كفيديوهات عادية.`)}catch(e){error(e.message)}finally{$('ytcheck').disabled=false}};
|
|
|
|
|
|
|
|
|
|
| 108 |
$('arctoggle').onclick=async()=>{try{error('');const cur=$('arctoggle').textContent.includes('تعطيل');await api('/api/archive/enable','POST',{enabled:!cur});await refresh()}catch(e){error(e.message)}};
|
| 109 |
$('arcscan').onclick=async()=>{try{error('');const r=await api('/api/archive/scan','POST');await refresh();if(!r.adopted)error('لا توجد ملفات جديدة خارج الطابور')}catch(e){error(e.message)}};
|
| 110 |
$('arcform').onsubmit=async e=>{e.preventDefault();try{error('');await api('/api/archive/settings','POST',{privacy:$('arcprivacy').value,fallback:$('arcfallback').value,title:$('arctitle').value,description:$('arcdesc').value,delete_after_upload:$('arcdelete').checked});arcLoaded=false;await refresh()}catch(e){error(e.message)}};
|
relay.py
DELETED
|
@@ -1,191 +0,0 @@
|
|
| 1 |
-
"""One FFmpeg push per live session. Multiple senders run concurrently, one per YouTube event."""
|
| 2 |
-
import os, time, threading, subprocess
|
| 3 |
-
from collections import deque
|
| 4 |
-
from curl_cffi import requests
|
| 5 |
-
from flv_bridge import FLVBridge, FormatChanged
|
| 6 |
-
|
| 7 |
-
class Sender:
|
| 8 |
-
"""Single YouTube RTMPS push for one session. Process and progress are instance-scoped,
|
| 9 |
-
so several senders can run at the same time without sharing state."""
|
| 10 |
-
def __init__(self,ffmpeg,log,headers_for=None):
|
| 11 |
-
self.ffmpeg=ffmpeg;self.log=log
|
| 12 |
-
self.headers_for=headers_for or (lambda user:{})
|
| 13 |
-
self.lock=threading.RLock();self.process=None
|
| 14 |
-
self.data={'status':'','source':'','output_seconds':0,'last_output_at':None}
|
| 15 |
-
# Timestamps of source resolution changes seen by this sender (anti-flap window).
|
| 16 |
-
self.flap=deque()
|
| 17 |
-
# Once the source proved to flap, stay on a constant canvas for the rest of the
|
| 18 |
-
# event. Without this the flap window expires after 10 quiet minutes and the
|
| 19 |
-
# sender silently drops back to copy mode, restarting the whole failure loop.
|
| 20 |
-
self.sticky_mode=None
|
| 21 |
-
def set(self,**kw):
|
| 22 |
-
with self.lock:self.data.update(kw)
|
| 23 |
-
def snapshot(self):
|
| 24 |
-
with self.lock:return dict(self.data)
|
| 25 |
-
def terminate(self):
|
| 26 |
-
with self.lock:p=self.process
|
| 27 |
-
if p and p.poll() is None:
|
| 28 |
-
try:p.terminate()
|
| 29 |
-
except OSError:pass
|
| 30 |
-
def send_mode(self):
|
| 31 |
-
"""copy -> forward the source untouched (original quality, no extra CPU)
|
| 32 |
-
fixed1080 -> re-encode onto a constant 1080x1920 canvas
|
| 33 |
-
fixed720 -> re-encode onto a constant 720x1280 canvas (lighter on CPU)
|
| 34 |
-
"""
|
| 35 |
-
mode=os.getenv('YOUTUBE_SEND_MODE','copy').strip().lower()
|
| 36 |
-
if mode not in ('copy','fixed1080','fixed720'):
|
| 37 |
-
raise ValueError('YOUTUBE_SEND_MODE يجب أن يكون copy أو fixed1080 أو fixed720')
|
| 38 |
-
# A source already proven to flap stays on the constant canvas for this whole event.
|
| 39 |
-
if self.sticky_mode:return self.sticky_mode
|
| 40 |
-
# copy mode only: if the phone keeps flipping resolution, stop flapping the
|
| 41 |
-
# YouTube session and absorb the changes on a constant canvas instead.
|
| 42 |
-
if mode=='copy':
|
| 43 |
-
flap=self.flap
|
| 44 |
-
now=time.monotonic()
|
| 45 |
-
while flap and now-flap[0]>600:flap.popleft()
|
| 46 |
-
if len(flap)>=int(os.getenv('YOUTUBE_FLAP_LIMIT','4')):
|
| 47 |
-
mode=os.getenv('YOUTUBE_FLAP_FALLBACK_MODE','fixed720').strip().lower()
|
| 48 |
-
if mode not in ('fixed1080','fixed720'):mode='fixed720'
|
| 49 |
-
self.sticky_mode=mode
|
| 50 |
-
return mode
|
| 51 |
-
def note_format_change(self):
|
| 52 |
-
self.flap.append(time.monotonic())
|
| 53 |
-
if len(self.flap)>=int(os.getenv('YOUTUBE_FLAP_LIMIT','4')):
|
| 54 |
-
self.log('system','تغيّرت أبعاد المصدر عدة مرات خلال 10 دقائق؛ التحول تلقائيًا إلى كانفس ثابت (%s) لمنع تكرار الانقطاع'%os.getenv('YOUTUBE_FLAP_FALLBACK_MODE','fixed720'))
|
| 55 |
-
@staticmethod
|
| 56 |
-
def canvas(width,height):
|
| 57 |
-
return ('scale=w=%d:h=%d:force_original_aspect_ratio=decrease:flags=lanczos,'
|
| 58 |
-
'pad=%d:%d:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,format=yuv420p'%(width,height,width,height))
|
| 59 |
-
def command(self,destination):
|
| 60 |
-
# Advertise Enhanced RTMP for network output; local test files need no protocol option.
|
| 61 |
-
mode=self.send_mode()
|
| 62 |
-
network=destination.startswith(('rtmp://','rtmps://'))
|
| 63 |
-
enhanced=['-rtmp_enhanced_codecs','hvc1'] if network and mode=='copy' and os.getenv('YOUTUBE_VIDEO_MODE','original').strip().lower()=='original' else []
|
| 64 |
-
if mode=='copy':
|
| 65 |
-
video=['-c:v','copy']
|
| 66 |
-
else:
|
| 67 |
-
width,height,bitrate=(1080,1920,'6000k') if mode=='fixed1080' else (720,1280,'4000k')
|
| 68 |
-
bufsize=str(int(int(bitrate[:-1])*1.5))+'k'
|
| 69 |
-
# A constant canvas is the only thing that guarantees YouTube always receives
|
| 70 |
-
# frames whose real size matches the signalled one, whatever the phone does.
|
| 71 |
-
video=['-vf',self.canvas(width,height),'-c:v','libx264','-preset',os.getenv('YOUTUBE_X264_PRESET','veryfast'),
|
| 72 |
-
'-tune','zerolatency','-profile:v','high','-crf',os.getenv('YOUTUBE_CRF','22'),
|
| 73 |
-
'-maxrate',bitrate,'-bufsize',bufsize,'-g','60','-keyint_min','30','-sc_threshold','0',
|
| 74 |
-
'-pix_fmt','yuv420p','-fps_mode','cfr',
|
| 75 |
-
'-colorspace','bt709','-color_primaries','bt709','-color_trc','bt709']
|
| 76 |
-
# Network output goes through the fifo pseudo-muxer instead of straight to flv.
|
| 77 |
-
# fifo separates encoding from the RTMPS connection and reconnects transparently
|
| 78 |
-
# when YouTube drops it, rather than killing the whole FFmpeg process. That is
|
| 79 |
-
# exactly why the local recording kept running while the broadcast died: writing
|
| 80 |
-
# to disk never fails, a network blip does, and with -f flv it was fatal.
|
| 81 |
-
# Documented: https://ffmpeg.org/ffmpeg-formats.html#fifo
|
| 82 |
-
# recover_any_error is deliberately NOT set, so permanent faults (bad stream key,
|
| 83 |
-
# rejected codec) still fail fast and stay visible in the log instead of retrying
|
| 84 |
-
# forever in silence.
|
| 85 |
-
if network:
|
| 86 |
-
# timeshift deliberately delays the YouTube feed behind the live TikTok source
|
| 87 |
-
# and keeps that much data buffered. Two effects, both free of CPU cost: a
|
| 88 |
-
# source hiccup is covered by the buffer instead of starving YouTube's ingest
|
| 89 |
-
# (which it reports as videoIngestionStarved = viewer buffering), and packets
|
| 90 |
-
# are retained across a reconnect instead of being dropped, which is what made
|
| 91 |
-
# segments go missing when the streamer restarted. Cost is latency only.
|
| 92 |
-
ts=int(os.getenv('YOUTUBE_FIFO_TIMESHIFT','10'))
|
| 93 |
-
# queue_size MUST grow with timeshift. Its default is 60 packets, which holds
|
| 94 |
-
# under a second at ~77 pkt/s (30 fps video + ~47 fps AAC); once timeshift
|
| 95 |
-
# delays the output the queue fills and, with drop_pkts_on_overflow=1, the
|
| 96 |
-
# stream is destroyed rather than delayed. Measured: ts=3 with the default
|
| 97 |
-
# queue yields 1 frame out of 480; ts=3 with 5000 and ts=10 with 20000 both
|
| 98 |
-
# yield byte-identical output to the no-fifo case.
|
| 99 |
-
q=max(500,int(os.getenv('YOUTUBE_FIFO_QUEUE','0')) or ts*250+500)
|
| 100 |
-
output=['-f','fifo','-fifo_format','flv','-format_opts','flvflags=no_duration_filesize',
|
| 101 |
-
'-drop_pkts_on_overflow','1','-attempt_recovery','1','-restart_with_keyframe','1',
|
| 102 |
-
'-recovery_wait_time',os.getenv('YOUTUBE_RECOVERY_WAIT','1'),
|
| 103 |
-
*(('-timeshift',str(ts),'-queue_size',str(q)) if ts>0 else ())]
|
| 104 |
-
else:
|
| 105 |
-
output=['-flvflags','no_duration_filesize','-f','flv']
|
| 106 |
-
return [self.ffmpeg,'-hide_banner','-loglevel','error','-nostats','-progress','pipe:1',
|
| 107 |
-
'-fflags','+genpts','-i','pipe:0','-map','0:v:0','-map','0:a:0?',
|
| 108 |
-
*video,'-c:a','aac','-b:a','128k','-ar','48000',
|
| 109 |
-
'-af','aresample=async=1:first_pts=0','-avoid_negative_ts','make_zero',
|
| 110 |
-
*enhanced,'-rw_timeout','20000000',*output,destination]
|
| 111 |
-
def send(self,user,candidate,destination,stop):
|
| 112 |
-
# YouTube's ingest is created with cdn.resolution/frameRate = 'variable', i.e. it
|
| 113 |
-
# auto-detects the incoming resolution, so a source resolution change does not
|
| 114 |
-
# require a new session. Tearing the session down is what produced the visible
|
| 115 |
-
# gaps and the reconnect storm. The guard stays available behind an env var in
|
| 116 |
-
# case YouTube's own healthStatus ever reports videoResolutionUnsupported.
|
| 117 |
-
guard=os.getenv('YOUTUBE_FORMAT_GUARD','off').lower() in ('1','on','true','yes')
|
| 118 |
-
bridge=FLVBridge(detect_format_change=(self.send_mode()=='copy' and guard))
|
| 119 |
-
response=None;p=None;error_reader=None;done=threading.Event()
|
| 120 |
-
# Set when we deliberately tear FFmpeg down because the source resolution
|
| 121 |
-
# changed; suppresses the misleading "FFmpeg exited with code N" log.
|
| 122 |
-
format_changed=False
|
| 123 |
-
progress={'last':time.monotonic(),'seconds':-1}
|
| 124 |
-
self.set(status='الاتصال بيوتيوب',source=candidate['label'],output_seconds=0,last_output_at=None)
|
| 125 |
-
try:
|
| 126 |
-
response=requests.get(candidate['url'],headers={'Referer':'https://www.tiktok.com/',**self.headers_for(user)},impersonate='chrome',stream=True,timeout=20)
|
| 127 |
-
response.raise_for_status()
|
| 128 |
-
p=subprocess.Popen(self.command(destination),stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,bufsize=0)
|
| 129 |
-
with self.lock:self.process=p
|
| 130 |
-
diagnostics=[]
|
| 131 |
-
def read_errors():
|
| 132 |
-
# Classify selected FFmpeg errors; never expose raw stderr or stream URLs.
|
| 133 |
-
patterns=[('Connection refused','رفض اتصال خادم الاستقبال'),('Connection timed out','انتهت مهلة اتصال خادم الاستقبال'),
|
| 134 |
-
('Connection reset by peer','خادم الاستقبال قطع الاتصال أثناء الإرسال'),('Broken pipe','انقطع أنبوب الإرسال إلى خادم الاستقبال'),
|
| 135 |
-
('End of file','خادم الاستقبال أغلق الاتصال'),('Immediate exit requested','طُلب إنهاء FFmpeg قبل اكتمال الإرسال'),
|
| 136 |
-
('Packet mismatch','عدم تطابق حزم الفيديو — تغيّر مقاس المصدر داخل الجلسة'),
|
| 137 |
-
('Input/output error','خطأ إدخال/إخراج في الإرسال'),('Server error','خادم الاستقبال أعاد خطأ'),
|
| 138 |
-
('Invalid data found','بيانات المصدر غير صالحة للقراءة'),('Video codec','عدم توافق ترميز الفيديو مع الحاوية'),
|
| 139 |
-
('Invalid argument','أحد إعدادات FFmpeg غير مقبول'),('Non-monotonic','توقيت الفيديو غير متزايد'),
|
| 140 |
-
('Unknown encoder','مرمز غير متوفر'),('Option not found','خيار FFmpeg غير مدعوم')]
|
| 141 |
-
try:
|
| 142 |
-
for line in iter(p.stderr.readline,b''):
|
| 143 |
-
text=line.decode('utf-8','replace')
|
| 144 |
-
for needle,label in patterns:
|
| 145 |
-
if needle.lower() in text.lower() and label not in diagnostics:diagnostics.append(label)
|
| 146 |
-
except (OSError,ValueError):pass
|
| 147 |
-
error_reader=threading.Thread(target=read_errors,daemon=True);error_reader.start()
|
| 148 |
-
def read_progress():
|
| 149 |
-
try:
|
| 150 |
-
for line in iter(p.stdout.readline,b''):
|
| 151 |
-
if line.startswith(b'out_time_us='):
|
| 152 |
-
try:seconds=int(line.split(b'=',1)[1])/1000000
|
| 153 |
-
except ValueError:continue
|
| 154 |
-
if seconds>progress['seconds']:
|
| 155 |
-
progress['seconds']=seconds;progress['last']=time.monotonic()
|
| 156 |
-
self.set(status='FFmpeg يرسل — تحقق من معاينة YouTube Studio',output_seconds=round(seconds,1),last_output_at=time.time())
|
| 157 |
-
except (OSError,ValueError):pass
|
| 158 |
-
def watchdog():
|
| 159 |
-
while not done.wait(1):
|
| 160 |
-
if stop.is_set() or time.monotonic()-progress['last']>60:
|
| 161 |
-
if p.poll() is None:
|
| 162 |
-
try:p.kill()
|
| 163 |
-
except OSError:pass
|
| 164 |
-
return
|
| 165 |
-
threading.Thread(target=read_progress,daemon=True).start()
|
| 166 |
-
threading.Thread(target=watchdog,daemon=True).start()
|
| 167 |
-
for chunk in response.iter_content():
|
| 168 |
-
if stop.is_set() or p.poll() is not None:break
|
| 169 |
-
if chunk:
|
| 170 |
-
# Unbuffered writes may be partial; send every byte.
|
| 171 |
-
view=memoryview(bridge.feed(chunk))
|
| 172 |
-
while view and not stop.is_set():
|
| 173 |
-
n=p.stdin.write(view)
|
| 174 |
-
if not n:raise BrokenPipeError()
|
| 175 |
-
view=view[n:]
|
| 176 |
-
except FormatChanged:
|
| 177 |
-
format_changed=True
|
| 178 |
-
raise
|
| 179 |
-
finally:
|
| 180 |
-
if response:response.close()
|
| 181 |
-
if p:
|
| 182 |
-
try:p.stdin.close()
|
| 183 |
-
except (OSError,ValueError):pass
|
| 184 |
-
try:p.wait(timeout=10)
|
| 185 |
-
except subprocess.TimeoutExpired:p.kill();p.wait()
|
| 186 |
-
done.set()
|
| 187 |
-
if error_reader:error_reader.join(timeout=1)
|
| 188 |
-
if p.returncode and not stop.is_set() and not format_changed:
|
| 189 |
-
self.log(user,'FFmpeg خرج برمز '+str(p.returncode)+(' — '+'؛ '.join(diagnostics[-3:]) if diagnostics else '؛ لم يظهر سبب مصنّف'))
|
| 190 |
-
with self.lock:self.process=None
|
| 191 |
-
return progress['seconds']>0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test_concurrency.py
DELETED
|
@@ -1,329 +0,0 @@
|
|
| 1 |
-
"""اختبار تكامل حقيقي للتوازي: عدة مذيعين يُرصدون ويُسجَّلون في نفس اللحظة،
|
| 2 |
-
والرفع إلى يوتيوب يجري بالتوازي مع استمرار الرصد والتسجيل.
|
| 3 |
-
|
| 4 |
-
لا إنترنت ولا أسرار. خادم محلي يبث FLV حقيقيًا بإيقاع زمني ويقطع الرابط في منتصف
|
| 5 |
-
البث (كما يفعل تيك توك)، وخادم ثانٍ يحاكي بروتوكول الرفع القابل للاستئناف من جوجل.
|
| 6 |
-
المقاييس تُؤخذ من الزمن الفعلي لا من الادّعاء.
|
| 7 |
-
"""
|
| 8 |
-
import json, os, re, shutil, sys, tempfile, threading, time, subprocess
|
| 9 |
-
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
| 10 |
-
from pathlib import Path
|
| 11 |
-
|
| 12 |
-
SRC = Path(__file__).resolve().parent
|
| 13 |
-
sys.path.insert(0, str(SRC))
|
| 14 |
-
|
| 15 |
-
FFMPEG = os.getenv('TEST_FFMPEG', '/home/user/bin/ffmpeg')
|
| 16 |
-
USERS = ['alpha', 'beta', 'gamma', 'delta']
|
| 17 |
-
# كم ثانية يستمر كل جزء من البث، ولكل مذيع عدد أجزاء. الأجزاء = تجديد رابط تيك توك.
|
| 18 |
-
PLAN = {'alpha': 2, 'beta': 2, 'gamma': 4, 'delta': 5}
|
| 19 |
-
PACE = 3.0 # ثوانٍ لبث كل جزء (إيقاع واقعي، لا دفعة واحدة)
|
| 20 |
-
CLIP_SECONDS = 6 # طول المقطع الواحد بالفيديو
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def probe_seconds(path):
|
| 24 |
-
out = subprocess.run([FFMPEG, '-i', str(path)], capture_output=True, text=True).stderr
|
| 25 |
-
m = re.search(r'Duration: (\d+):(\d+):([\d.]+)', out)
|
| 26 |
-
return int(m.group(1)) * 3600 + int(m.group(2)) * 60 + float(m.group(3)) if m else 0.0
|
| 27 |
-
|
| 28 |
-
TL = [] # الخط الزمني: (نوع, اسم, بداية, نهاية)
|
| 29 |
-
TL_LOCK = threading.Lock()
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
def mark(kind, who, t0, t1):
|
| 33 |
-
with TL_LOCK:
|
| 34 |
-
TL.append((kind, who, t0, t1))
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
def overlap(a, b):
|
| 38 |
-
return max(0.0, min(a[3], b[3]) - max(a[2], b[2]))
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
# ───────────────────────── خادم تيك توك الوهمي ─────────────────────────
|
| 42 |
-
class TikTokHandler(BaseHTTPRequestHandler):
|
| 43 |
-
protocol_version = 'HTTP/1.1'
|
| 44 |
-
payload = b''
|
| 45 |
-
|
| 46 |
-
def log_message(self, *a):
|
| 47 |
-
pass
|
| 48 |
-
|
| 49 |
-
def do_GET(self):
|
| 50 |
-
self.send_response(200)
|
| 51 |
-
self.send_header('Content-Type', 'video/x-flv')
|
| 52 |
-
self.send_header('Transfer-Encoding', 'chunked')
|
| 53 |
-
self.end_headers()
|
| 54 |
-
data = TikTokHandler.payload
|
| 55 |
-
step = max(16384, len(data) // 30)
|
| 56 |
-
started = time.monotonic()
|
| 57 |
-
sent = 0
|
| 58 |
-
try:
|
| 59 |
-
while sent < len(data):
|
| 60 |
-
blob = data[sent:sent + step]
|
| 61 |
-
self.wfile.write(b'%x\r\n' % len(blob) + blob + b'\r\n')
|
| 62 |
-
self.wfile.flush()
|
| 63 |
-
sent += len(blob)
|
| 64 |
-
target = started + PACE * (sent / len(data))
|
| 65 |
-
time.sleep(max(0, target - time.monotonic()))
|
| 66 |
-
self.wfile.write(b'0\r\n\r\n') # انتهى الجزء: تيك توك جدّد الرابط
|
| 67 |
-
except (BrokenPipeError, ConnectionResetError, OSError):
|
| 68 |
-
pass
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
# ───────────────────────── خادم يوتيوب الوهمي ─────────────────────────
|
| 72 |
-
YT = {'sessions': {}, 'next': 1, 'videos': {}, 'uploads': 0}
|
| 73 |
-
UPLOAD_DELAY = 2.5 # تأخير كل دفعة ليصير الرفع محسوسًا زمنيًا
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
class YouTubeHandler(BaseHTTPRequestHandler):
|
| 77 |
-
protocol_version = 'HTTP/1.1'
|
| 78 |
-
|
| 79 |
-
def log_message(self, *a):
|
| 80 |
-
pass
|
| 81 |
-
|
| 82 |
-
def _json(self, code, payload, headers=None):
|
| 83 |
-
body = json.dumps(payload).encode()
|
| 84 |
-
self.send_response(code)
|
| 85 |
-
for k, v in (headers or {}).items():
|
| 86 |
-
self.send_header(k, v)
|
| 87 |
-
self.send_header('Content-Type', 'application/json')
|
| 88 |
-
self.send_header('Content-Length', str(len(body)))
|
| 89 |
-
self.end_headers()
|
| 90 |
-
self.wfile.write(body)
|
| 91 |
-
|
| 92 |
-
def do_POST(self):
|
| 93 |
-
self.rfile.read(int(self.headers.get('Content-Length') or 0))
|
| 94 |
-
YT['uploads'] += 1
|
| 95 |
-
sid = str(YT['next'])
|
| 96 |
-
YT['next'] += 1
|
| 97 |
-
YT['sessions'][sid] = 0
|
| 98 |
-
self.send_response(200)
|
| 99 |
-
self.send_header('Location', f'http://127.0.0.1:{YT_PORT}/session/{sid}')
|
| 100 |
-
self.send_header('Content-Length', '0')
|
| 101 |
-
self.end_headers()
|
| 102 |
-
|
| 103 |
-
def do_PUT(self):
|
| 104 |
-
length = int(self.headers.get('Content-Length') or 0)
|
| 105 |
-
self.rfile.read(length)
|
| 106 |
-
time.sleep(UPLOAD_DELAY)
|
| 107 |
-
sid = self.path.rsplit('/', 1)[-1]
|
| 108 |
-
rng = self.headers.get('Content-Range') or ''
|
| 109 |
-
m = re.match(r'bytes (\d+)-(\d+)/(\d+)', rng)
|
| 110 |
-
if not m:
|
| 111 |
-
self._json(200, {'id': 'vid' + sid})
|
| 112 |
-
return
|
| 113 |
-
start, end, total = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
| 114 |
-
YT['sessions'][sid] = end + 1
|
| 115 |
-
if end + 1 >= total:
|
| 116 |
-
vid = 'vid' + sid
|
| 117 |
-
YT['videos'][vid] = {'uploadStatus': 'processed', 'privacyStatus': 'public',
|
| 118 |
-
'rejectionReason': ''}
|
| 119 |
-
self._json(200, {'id': vid})
|
| 120 |
-
else:
|
| 121 |
-
self.send_response(308)
|
| 122 |
-
self.send_header('Range', f'bytes=0-{end}')
|
| 123 |
-
self.send_header('Content-Length', '0')
|
| 124 |
-
self.end_headers()
|
| 125 |
-
|
| 126 |
-
def do_GET(self):
|
| 127 |
-
vid = re.search(r'id=([^&]+)', self.path)
|
| 128 |
-
vid = vid.group(1) if vid else ''
|
| 129 |
-
info = YT['videos'].get(vid)
|
| 130 |
-
if not info:
|
| 131 |
-
self._json(200, {'items': []})
|
| 132 |
-
return
|
| 133 |
-
self._json(200, {'items': [{'id': vid, 'status': info,
|
| 134 |
-
'snippet': {'title': 't'}, 'processingDetails': {}}]})
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
def serve(handler):
|
| 138 |
-
srv = ThreadingHTTPServer(('127.0.0.1', 0), handler)
|
| 139 |
-
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
| 140 |
-
return srv, srv.server_address[1]
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
# ───────────────────────── تجهيز مادة FLV حقيقية ─────────────────────────
|
| 144 |
-
def build_flv(path, seconds=6):
|
| 145 |
-
subprocess.run([FFMPEG, '-v', 'error', '-y',
|
| 146 |
-
'-f', 'lavfi', '-i', f'testsrc=size=256x144:rate=12:duration={seconds}',
|
| 147 |
-
'-f', 'lavfi', '-i', f'sine=frequency=440:duration={seconds}',
|
| 148 |
-
'-c:v', 'libx264', '-preset', 'ultrafast', '-g', '24',
|
| 149 |
-
'-c:a', 'aac', '-t', str(seconds), str(path)], check=True)
|
| 150 |
-
return path.read_bytes()
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
def main():
|
| 154 |
-
global YT_PORT
|
| 155 |
-
tmp = Path(tempfile.mkdtemp())
|
| 156 |
-
os.environ.update(
|
| 157 |
-
PASSWORD='admin', DATA_DIR=str(tmp),
|
| 158 |
-
ARCHIVE_MIN_MB='0', ARCHIVE_CHUNK_MB='1', ARCHIVE_IDLE_SECONDS='3',
|
| 159 |
-
ARCHIVE_VERIFY_ROUNDS='2', ARCHIVE_VERIFY_WAIT_SECONDS='5',
|
| 160 |
-
ARCHIVE_VERIFY_INLINE_SECONDS='5', ARCHIVE_DELETE_AFTER_UPLOAD='off',
|
| 161 |
-
CHECK_INTERVAL='3', MIN_FREE_MB='1', ENSURE_FREE_MB='1', KEEP_LAST_FILES='50',
|
| 162 |
-
PATH='/home/user/bin:' + os.environ.get('PATH', ''))
|
| 163 |
-
|
| 164 |
-
flv = build_flv(tmp / 'src.flv', CLIP_SECONDS)
|
| 165 |
-
TikTokHandler.payload = flv
|
| 166 |
-
tk_srv, tk_port = serve(TikTokHandler)
|
| 167 |
-
yt_srv, YT_PORT = serve(YouTubeHandler)
|
| 168 |
-
os.environ['ARCHIVE_UPLOAD_ENDPOINT'] = f'http://127.0.0.1:{YT_PORT}/upload'
|
| 169 |
-
|
| 170 |
-
import app
|
| 171 |
-
|
| 172 |
-
# ── طبقة تيك توك وهمية: كل مذيع يبث عدة أجزاء ثم ينتهي ──
|
| 173 |
-
progress = {u: 0 for u in USERS}
|
| 174 |
-
plock = threading.Lock()
|
| 175 |
-
|
| 176 |
-
def fake_observe(user):
|
| 177 |
-
with plock:
|
| 178 |
-
done = progress[user] >= PLAN[user]
|
| 179 |
-
return {'status': 'offline' if done else 'live', 'room_id': '100',
|
| 180 |
-
'sources': [] if done else [{'url': f'http://127.0.0.1:{tk_port}/live/{user}',
|
| 181 |
-
'label': 'origin · 256x144 · h264'}],
|
| 182 |
-
'nickname': 'مذيع ' + user, 'live_title': f'بث تجريبي #{user}'}
|
| 183 |
-
|
| 184 |
-
def fake_extract(user):
|
| 185 |
-
with plock:
|
| 186 |
-
if progress[user] >= PLAN[user]:
|
| 187 |
-
return []
|
| 188 |
-
progress[user] += 1
|
| 189 |
-
return fake_observe(user)['sources'] or [
|
| 190 |
-
{'url': f'http://127.0.0.1:{tk_port}/live/{user}', 'label': 'origin · 256x144 · h264'}]
|
| 191 |
-
|
| 192 |
-
app.observe_session = fake_observe
|
| 193 |
-
app._observe_session = fake_observe
|
| 194 |
-
app.extract = fake_extract
|
| 195 |
-
|
| 196 |
-
# ── واجهة يوتيوب وهمية للأرشيف ──
|
| 197 |
-
class FakeAPI:
|
| 198 |
-
def configured(self):
|
| 199 |
-
return True
|
| 200 |
-
|
| 201 |
-
def token(self):
|
| 202 |
-
return 'fake'
|
| 203 |
-
|
| 204 |
-
def call(self, resource, method='GET', params=None, body=None):
|
| 205 |
-
from urllib.request import Request, urlopen
|
| 206 |
-
from urllib.parse import urlencode
|
| 207 |
-
url = f'http://127.0.0.1:{YT_PORT}/v3/' + resource
|
| 208 |
-
if params:
|
| 209 |
-
url += '?' + urlencode(params)
|
| 210 |
-
data = json.dumps(body).encode() if body is not None else None
|
| 211 |
-
req = Request(url, data=data, method=method,
|
| 212 |
-
headers={'Content-Type': 'application/json'})
|
| 213 |
-
with urlopen(req, timeout=20) as r:
|
| 214 |
-
raw = r.read()
|
| 215 |
-
return json.loads(raw) if raw else {}
|
| 216 |
-
|
| 217 |
-
app.archive.api = FakeAPI()
|
| 218 |
-
app.archive.journal.reserve = lambda *a, **k: None
|
| 219 |
-
app.archive.budget_left = lambda: 10 ** 9
|
| 220 |
-
|
| 221 |
-
# ── قياس: متى يسجّل كل مذيع، ومتى يُرفع كل ملف ──
|
| 222 |
-
real_record = app.record
|
| 223 |
-
|
| 224 |
-
def timed_record(user, candidate, stop):
|
| 225 |
-
t0 = time.monotonic()
|
| 226 |
-
try:
|
| 227 |
-
return real_record(user, candidate, stop)
|
| 228 |
-
finally:
|
| 229 |
-
mark('record', user, t0, time.monotonic())
|
| 230 |
-
|
| 231 |
-
app.record = timed_record
|
| 232 |
-
|
| 233 |
-
real_process = app.archive._process
|
| 234 |
-
|
| 235 |
-
def timed_process(name):
|
| 236 |
-
t0 = time.monotonic()
|
| 237 |
-
try:
|
| 238 |
-
return real_process(name)
|
| 239 |
-
finally:
|
| 240 |
-
mark('upload', name, t0, time.monotonic())
|
| 241 |
-
|
| 242 |
-
app.archive._process = timed_process
|
| 243 |
-
|
| 244 |
-
for u in USERS:
|
| 245 |
-
app.tasks[u] = {'enabled': True, 'status': 'متوقف'}
|
| 246 |
-
app.archive.start()
|
| 247 |
-
t_zero = time.monotonic()
|
| 248 |
-
for u in USERS:
|
| 249 |
-
app.start(u)
|
| 250 |
-
|
| 251 |
-
# ── مراقبة لحظية: كم مذيعًا يسجّل في نفس اللحظة ──
|
| 252 |
-
peak_recording = 0
|
| 253 |
-
peak_record_while_upload = 0
|
| 254 |
-
deadline = time.monotonic() + 180
|
| 255 |
-
while time.monotonic() < deadline:
|
| 256 |
-
time.sleep(0.4)
|
| 257 |
-
with app.lock:
|
| 258 |
-
rec = [u for u, t in app.tasks.items() if 'تسجيل' in str(t.get('status', ''))]
|
| 259 |
-
uploading = app.archive.current is not None
|
| 260 |
-
peak_recording = max(peak_recording, len(rec))
|
| 261 |
-
if uploading:
|
| 262 |
-
peak_record_while_upload = max(peak_record_while_upload, len(rec))
|
| 263 |
-
with app.lock:
|
| 264 |
-
all_done = all(app.tasks[u]['status'] in ('البث منتهٍ', 'متوقف') for u in USERS)
|
| 265 |
-
if all_done and not app.archive.read()['items'] and time.monotonic() - t_zero > 10:
|
| 266 |
-
break
|
| 267 |
-
|
| 268 |
-
for h in list(app.handles.values()):
|
| 269 |
-
h['stop'].set()
|
| 270 |
-
app.archive.stop()
|
| 271 |
-
time.sleep(1)
|
| 272 |
-
|
| 273 |
-
# ───────────────────────── التقرير ─────────────────────────
|
| 274 |
-
recs = [e for e in TL if e[0] == 'record']
|
| 275 |
-
ups = [e for e in TL if e[0] == 'upload']
|
| 276 |
-
print('\n' + '=' * 62)
|
| 277 |
-
print('الخط الزمني (ثوانٍ من البداية)')
|
| 278 |
-
print('=' * 62)
|
| 279 |
-
for kind, who, a, b in sorted(TL, key=lambda e: e[2]):
|
| 280 |
-
bar = ' ' * int((a - t_zero) * 2) + ('█' if kind == 'record' else '▓') * max(1, int((b - a) * 2))
|
| 281 |
-
print(f'{kind:7} {who[:34]:34} {a-t_zero:6.1f}→{b-t_zero:6.1f} {bar}')
|
| 282 |
-
|
| 283 |
-
# أقصى تداخل فعلي بين التسجيلات
|
| 284 |
-
max_parallel = 0
|
| 285 |
-
for probe in [e[2] + 0.05 for e in recs]:
|
| 286 |
-
max_parallel = max(max_parallel, sum(1 for e in recs if e[2] <= probe <= e[3]))
|
| 287 |
-
rec_up_overlap = max((overlap(r, u) for r in recs for u in ups), default=0.0)
|
| 288 |
-
|
| 289 |
-
hist = app.archive.read()
|
| 290 |
-
done = [i for i in hist['items'].values() if i.get('state') == 'done'] + \
|
| 291 |
-
[i for i in hist.get('history', []) if i.get('state') == 'done']
|
| 292 |
-
|
| 293 |
-
print('\n' + '=' * 62)
|
| 294 |
-
checks = [
|
| 295 |
-
('رصد جميع المذيعين في نفس اللحظة', peak_recording == len(USERS), f'{peak_recording}/{len(USERS)}'),
|
| 296 |
-
('تسجيل متوازٍ فعلي (تداخل زمني)', max_parallel == len(USERS), f'{max_parallel} معًا'),
|
| 297 |
-
('التسجيل مستمر أثناء الرفع', rec_up_overlap > 1.0, f'{rec_up_overlap:.1f}ث تداخل'),
|
| 298 |
-
('رصد مستمر أثناء الرفع', peak_record_while_upload >= 1, f'{peak_record_while_upload} مذيعًا'),
|
| 299 |
-
('ملف واحد لكل مذيع رغم تجديد الرابط', len(recs) == len(USERS), f'{len(recs)} تسجيلًا'),
|
| 300 |
-
('كل التسجيلات رُفعت وتحققت', len(done) == len(USERS), f'{len(done)}/{len(USERS)}'),
|
| 301 |
-
('رفعة واحدة لكل مذيع (لا هدر كوتا)', YT['uploads'] == len(USERS), f"{YT['uploads']} جلسة رفع"),
|
| 302 |
-
]
|
| 303 |
-
# ── أهم تحقق: لا ثانية واحدة ضاعت عند لصق المصادر ──
|
| 304 |
-
for u in USERS:
|
| 305 |
-
f = next((x for x in (tmp / 'recordings').glob(f'{u}_*.mp4')), None)
|
| 306 |
-
want = PLAN[u] * CLIP_SECONDS
|
| 307 |
-
got = probe_seconds(f) if f else 0.0
|
| 308 |
-
checks.append((f'مدة ملف @{u} = مجموع الأجزاء',
|
| 309 |
-
f is not None and abs(got - want) <= 1.0,
|
| 310 |
-
f'{got:.1f}ث مقابل {want}ث متوقعة ({PLAN[u]} أجزاء)'))
|
| 311 |
-
ok = True
|
| 312 |
-
for label, passed, detail in checks:
|
| 313 |
-
ok &= passed
|
| 314 |
-
print(f'{"✅" if passed else "❌"} {label:42} {detail}')
|
| 315 |
-
print('=' * 62)
|
| 316 |
-
|
| 317 |
-
# تحقق من سلامة الملفات الناتجة (قبل حذفها بعد الرفع نقيس من السجل)
|
| 318 |
-
print('\nسجلّ اللصق كما كتبه البرنامج:')
|
| 319 |
-
for line in app.events[-500:]:
|
| 320 |
-
txt = json.dumps(line, ensure_ascii=False)
|
| 321 |
-
if 'ملصوقة' in txt or 'أُعيد وصل' in txt:
|
| 322 |
-
print(' •', txt[:150])
|
| 323 |
-
|
| 324 |
-
shutil.rmtree(tmp, ignore_errors=True)
|
| 325 |
-
return 0 if ok else 1
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
if __name__ == '__main__':
|
| 329 |
-
sys.exit(main())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test_geometry.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""اختبارات إصلاح الجولات الرباعية: تغيّر مقاس/إعداد الصورة وسط البث.
|
| 2 |
+
|
| 3 |
+
ما تثبته هذه الاختبارات على ملفات حقيقية يبنيها ffmpeg:
|
| 4 |
+
١) المسجّل يتتبّع إعداد الصورة **الفعّال الآن**: دخول الجولة (إعداد B) ثم الخروج
|
| 5 |
+
منها (عودة الإعداد A) يُكتبان معًا، ولا يُسقط وسم العودة باعتباره «مكررًا».
|
| 6 |
+
إسقاطه كان يترك المفكّك على SPS الجولة فتخرج بقية البث صورة مشوّهة.
|
| 7 |
+
٢) الوسم المكرر فعلًا (نفس الإعداد الفعّال) ما زال يُسقط — لا نُفسد ملفات MKV.
|
| 8 |
+
٣) التسجيل الذي يحمل أكثر من مقاس يخرج MP4 بإطار واحد ثابت، بلا أي خطأ فك ترميز،
|
| 9 |
+
مع هوامش سوداء تحفظ نسبة المشهد بدل تمطيطه.
|
| 10 |
+
٤) التسجيل العادي (مقاس واحد) يبقى **نسخًا بلا إعادة ترميز**: إطاراته المفكوكة
|
| 11 |
+
مطابقة بصمةً للمصدر.
|
| 12 |
+
|
| 13 |
+
التشغيل: python test_geometry.py
|
| 14 |
+
"""
|
| 15 |
+
import hashlib, os, shutil, subprocess, sys, tempfile, unittest
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
WORK = Path(tempfile.mkdtemp(prefix='geometry-test-'))
|
| 19 |
+
os.environ.setdefault('PASSWORD', 'test')
|
| 20 |
+
os.environ['DATA_DIR'] = str(WORK / 'storage')
|
| 21 |
+
os.environ.setdefault('ARCHIVE_ENABLED', 'off')
|
| 22 |
+
os.environ.setdefault('GEOMETRY_SCAN', 'auto')
|
| 23 |
+
|
| 24 |
+
import app # noqa: E402 (يحتاج المتغيّرات أعلاه قبل الاستيراد)
|
| 25 |
+
|
| 26 |
+
FFMPEG, FFPROBE = app.FFMPEG, app.FFPROBE
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def make_clip(path, w, h, seconds=4, rate=15):
|
| 30 |
+
subprocess.run([FFMPEG, '-v', 'error', '-y',
|
| 31 |
+
'-f', 'lavfi', '-i', f'testsrc2=size={w}x{h}:rate={rate}:duration={seconds}',
|
| 32 |
+
'-f', 'lavfi', '-i', f'sine=frequency=440:duration={seconds}',
|
| 33 |
+
'-c:v', 'libx264', '-preset', 'ultrafast', '-g', '15', '-pix_fmt', 'yuv420p',
|
| 34 |
+
'-c:a', 'aac', '-ar', '44100', '-shortest', str(path)], check=True)
|
| 35 |
+
return path
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def flv_tags(path):
|
| 39 |
+
"""(الترويسة, قائمة الوسوم) لملف FLV."""
|
| 40 |
+
data = path.read_bytes()
|
| 41 |
+
size = int.from_bytes(data[5:9], 'big') + 4
|
| 42 |
+
tags, i = [], size
|
| 43 |
+
while i + 11 <= len(data):
|
| 44 |
+
n = 11 + int.from_bytes(data[i + 1:i + 4], 'big') + 4
|
| 45 |
+
if i + n > len(data):
|
| 46 |
+
break
|
| 47 |
+
tags.append(bytearray(data[i:i + n]))
|
| 48 |
+
i += n
|
| 49 |
+
return data[:size], tags
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def splice_same_source(out, parts):
|
| 53 |
+
"""يبني FLV واحدًا يحاكي ما يصل من تيك توك: نفس الاتصال، والمقاس يتغيّر وسطه."""
|
| 54 |
+
blob = bytearray()
|
| 55 |
+
offset = last = 0
|
| 56 |
+
for n, part in enumerate(parts):
|
| 57 |
+
header, tags = flv_tags(part)
|
| 58 |
+
if n == 0:
|
| 59 |
+
blob += header
|
| 60 |
+
base = None
|
| 61 |
+
for tag in tags:
|
| 62 |
+
if n and (tag[0] & 31) == 18: # onMetaData المكرر لا يُرسله تيك توك مرتين
|
| 63 |
+
continue
|
| 64 |
+
ts = (tag[4] << 16) | (tag[5] << 8) | tag[6] | (tag[7] << 24)
|
| 65 |
+
base = ts if base is None else base
|
| 66 |
+
new = ts - base + offset
|
| 67 |
+
tag[4] = (new >> 16) & 0xFF; tag[5] = (new >> 8) & 0xFF
|
| 68 |
+
tag[6] = new & 0xFF; tag[7] = (new >> 24) & 0xFF
|
| 69 |
+
last = max(last, new)
|
| 70 |
+
blob += tag
|
| 71 |
+
offset = last + 40
|
| 72 |
+
out.write_bytes(bytes(blob))
|
| 73 |
+
return out
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def decode_errors(path):
|
| 77 |
+
r = subprocess.run([FFMPEG, '-v', 'warning', '-i', str(path), '-map', '0:v:0', '-f', 'null', '-'],
|
| 78 |
+
capture_output=True, text=True)
|
| 79 |
+
return sum(1 for line in r.stderr.splitlines() if 'error while decoding' in line)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def frame_sizes(path):
|
| 83 |
+
r = subprocess.run([FFPROBE, '-v', 'error', '-select_streams', 'v:0', '-skip_frame', 'nokey',
|
| 84 |
+
'-show_entries', 'frame=width,height', '-of', 'csv=p=0', str(path)],
|
| 85 |
+
capture_output=True, text=True)
|
| 86 |
+
out = set()
|
| 87 |
+
for line in r.stdout.split():
|
| 88 |
+
nums = [int(x) for x in line.replace(',', ' ').split() if x.isdigit()]
|
| 89 |
+
if len(nums) >= 2:
|
| 90 |
+
out.add((nums[0], nums[1]))
|
| 91 |
+
return out
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def video_fingerprint(path):
|
| 95 |
+
"""بصمة الإطارات المفكوكة: تتطابق فقط إذا لم تحدث إعادة ترميز."""
|
| 96 |
+
r = subprocess.run([FFMPEG, '-v', 'error', '-i', str(path), '-map', '0:v:0',
|
| 97 |
+
'-f', 'md5', '-'], capture_output=True, text=True, check=True)
|
| 98 |
+
return r.stdout.strip()
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def pixel(path, when, x, y):
|
| 102 |
+
"""لون بكسل واحد من إطار عند لحظة معيّنة — للتأكد أن الهامش أسود والصورة في الوسط."""
|
| 103 |
+
info = subprocess.run([FFPROBE, '-v', 'error', '-select_streams', 'v:0',
|
| 104 |
+
'-show_entries', 'stream=width,height', '-of', 'csv=p=0', str(path)],
|
| 105 |
+
capture_output=True, text=True, check=True).stdout
|
| 106 |
+
w, h = [int(n) for n in info.replace(',', ' ').split()[:2]]
|
| 107 |
+
raw = subprocess.run([FFMPEG, '-v', 'error', '-ss', str(when), '-i', str(path), '-frames:v', '1',
|
| 108 |
+
'-f', 'rawvideo', '-pix_fmt', 'rgb24', '-'],
|
| 109 |
+
capture_output=True, check=True).stdout
|
| 110 |
+
i = ((y % h) * w + (x % w)) * 3
|
| 111 |
+
return tuple(raw[i:i + 3])
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def config_tag_count(path):
|
| 115 |
+
"""عدد وسوم إعداد الفيديو (Sequence Header) المكتوبة فعلًا في الملف."""
|
| 116 |
+
_, tags = flv_tags(path)
|
| 117 |
+
n = 0
|
| 118 |
+
for tag in tags:
|
| 119 |
+
if (tag[0] & 31) == 9 and len(tag) > 13 and app._looks_like_config(9, tag[11], tag[12]):
|
| 120 |
+
n += 1
|
| 121 |
+
return n
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
class WriterTest(unittest.TestCase):
|
| 125 |
+
"""المسجّل نفسه: ماذا يكتب وماذا يُسقط عند تغيّر إعداد الصورة."""
|
| 126 |
+
|
| 127 |
+
@classmethod
|
| 128 |
+
def setUpClass(cls):
|
| 129 |
+
cls.dir = WORK / 'writer'
|
| 130 |
+
cls.dir.mkdir(parents=True, exist_ok=True)
|
| 131 |
+
cls.portrait = make_clip(cls.dir / 'portrait.flv', 320, 568)
|
| 132 |
+
cls.round4 = make_clip(cls.dir / 'round.flv', 568, 320)
|
| 133 |
+
|
| 134 |
+
def write_through(self, source, chunk=4096):
|
| 135 |
+
"""يمرّر الملف على FLVWriter بقطع شبكية عشوائية الحدود كما يصل فعلًا."""
|
| 136 |
+
out = self.dir / ('written-' + source.stem + '.flv')
|
| 137 |
+
data = source.read_bytes()
|
| 138 |
+
with open(out, 'wb') as fh:
|
| 139 |
+
writer = app.FLVWriter(fh)
|
| 140 |
+
for i in range(0, len(data), chunk):
|
| 141 |
+
writer.feed(data[i:i + chunk])
|
| 142 |
+
return out, writer
|
| 143 |
+
|
| 144 |
+
def test_round_entry_and_exit_are_both_kept(self):
|
| 145 |
+
"""دخول الجولة ثم الخروج منها: إعدادان مختلفان + عودة الأول = ثلاثة وسوم محفوظة."""
|
| 146 |
+
mixed = splice_same_source(self.dir / 'mixed.flv',
|
| 147 |
+
[self.portrait, self.round4, self.portrait])
|
| 148 |
+
self.assertEqual(config_tag_count(mixed), 3, 'ملف الاختبار نفسه يجب أن يحمل ثلاثة وسوم إعداد')
|
| 149 |
+
out, writer = self.write_through(mixed)
|
| 150 |
+
self.assertEqual(writer.video_changes, 2, 'يجب إحصاء تغيّرتين: دخول الجولة والخروج منها')
|
| 151 |
+
self.assertEqual(config_tag_count(out), 3, 'وسم العودة للمقاس الأصلي لا يجوز إسقاطه')
|
| 152 |
+
self.assertEqual(decode_errors(out), 0, 'الملف الخام يجب أن يُفكّ بلا أخطاء')
|
| 153 |
+
self.assertEqual(frame_sizes(out), {(320, 568), (568, 320)})
|
| 154 |
+
|
| 155 |
+
def test_identical_repeated_config_is_dropped(self):
|
| 156 |
+
"""الوسم المطابق للإعداد الفعّال يبقى مُسقطًا (وإلا فسد MKV بعنصر مكرر)."""
|
| 157 |
+
header, tags = flv_tags(self.portrait)
|
| 158 |
+
cfg = next(t for t in tags
|
| 159 |
+
if (t[0] & 31) == 9 and len(t) > 13 and app._looks_like_config(9, t[11], t[12]))
|
| 160 |
+
stuffed = self.dir / 'stuffed.flv'
|
| 161 |
+
blob = bytearray(header)
|
| 162 |
+
for n, tag in enumerate(tags):
|
| 163 |
+
blob += tag
|
| 164 |
+
if n == 30:
|
| 165 |
+
blob += cfg # تيك توك يعيد إرسال نفس الإعداد أحيانًا
|
| 166 |
+
stuffed.write_bytes(bytes(blob))
|
| 167 |
+
self.assertEqual(config_tag_count(stuffed), 2)
|
| 168 |
+
out, writer = self.write_through(stuffed)
|
| 169 |
+
self.assertEqual(writer.video_changes, 0)
|
| 170 |
+
self.assertEqual(config_tag_count(out), 1, 'التكرار الحقيقي يُسقط')
|
| 171 |
+
|
| 172 |
+
def test_plain_stream_is_untouched(self):
|
| 173 |
+
"""بث عادي بلا جولات: بايتات الخرج = بايتات الدخل، ولا تغيّرات محسوبة."""
|
| 174 |
+
out, writer = self.write_through(self.portrait)
|
| 175 |
+
self.assertEqual(writer.video_changes, 0)
|
| 176 |
+
self.assertEqual(hashlib.md5(out.read_bytes()).hexdigest(),
|
| 177 |
+
hashlib.md5(self.portrait.read_bytes()).hexdigest())
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
class FinalizeTest(unittest.TestCase):
|
| 181 |
+
"""مرحلة التجهيز: متى يُوحَّد الإطار ومتى يبقى النسخ كما هو."""
|
| 182 |
+
|
| 183 |
+
@classmethod
|
| 184 |
+
def setUpClass(cls):
|
| 185 |
+
cls.dir = WORK / 'finalize'
|
| 186 |
+
cls.dir.mkdir(parents=True, exist_ok=True)
|
| 187 |
+
cls.portrait = make_clip(cls.dir / 'p.flv', 320, 568, seconds=6)
|
| 188 |
+
cls.round4 = make_clip(cls.dir / 'r.flv', 568, 320, seconds=3)
|
| 189 |
+
|
| 190 |
+
def setUp(self):
|
| 191 |
+
for leftover in app.FILES.iterdir():
|
| 192 |
+
if leftover.is_file():
|
| 193 |
+
leftover.unlink()
|
| 194 |
+
|
| 195 |
+
def run_finalize(self, source, stem, changes):
|
| 196 |
+
raw = app.FILES / (stem + '.partial.flv')
|
| 197 |
+
shutil.copyfile(source, raw)
|
| 198 |
+
app.finalize(raw, stem, 'tester', {'duration': 9}, changes)
|
| 199 |
+
return app.FILES / (stem + '.mp4')
|
| 200 |
+
|
| 201 |
+
def test_mixed_geometry_is_normalized(self):
|
| 202 |
+
mixed = splice_same_source(self.dir / 'mixed.flv',
|
| 203 |
+
[self.portrait, self.round4, self.portrait])
|
| 204 |
+
# المسار القديم (mkvmerge ثم نسخ) يفقد الإعداد الثاني: هذا هو سبب التشويه.
|
| 205 |
+
broken = self.dir / 'broken.mkv'
|
| 206 |
+
subprocess.run([app.MKVMERGE, '-o', str(broken), str(mixed)],
|
| 207 |
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
| 208 |
+
self.assertGreater(decode_errors(broken), 0,
|
| 209 |
+
'لو لم يُفسد mkvmerge الملف لما كانت هناك مشكلة أصلًا')
|
| 210 |
+
|
| 211 |
+
out = self.run_finalize(mixed, 'tester_20260101_000000_aa', changes=2)
|
| 212 |
+
self.assertTrue(out.exists(), 'يجب إنتاج MP4')
|
| 213 |
+
self.assertEqual(frame_sizes(out), {(320, 568)}, 'إطار واحد ثابت = المقاس الغالب')
|
| 214 |
+
self.assertEqual(decode_errors(out), 0, 'لا خطأ فك ترميز واحد في الناتج')
|
| 215 |
+
# مشهد الجولة (عرضي) يجب أن يُحاط بهوامش سوداء لا أن يُمطّط.
|
| 216 |
+
self.assertEqual(pixel(out, 7.0, 10, 10), (0, 0, 0), 'أعلى الإطار هامش أسود')
|
| 217 |
+
self.assertNotEqual(pixel(out, 7.0, 160, 284), (0, 0, 0), 'وسط الإطار فيه صورة')
|
| 218 |
+
|
| 219 |
+
def test_single_geometry_stays_a_copy(self):
|
| 220 |
+
out = self.run_finalize(self.portrait, 'tester_20260101_000001_bb', changes=0)
|
| 221 |
+
self.assertTrue(out.exists())
|
| 222 |
+
self.assertEqual(frame_sizes(out), {(320, 568)})
|
| 223 |
+
self.assertEqual(video_fingerprint(out), video_fingerprint(self.portrait),
|
| 224 |
+
'التسجيل العادي يجب أن يبقى نسخًا بلا إعادة ترميز')
|
| 225 |
+
|
| 226 |
+
def test_scan_off_restores_old_behaviour(self):
|
| 227 |
+
mixed = splice_same_source(self.dir / 'mixed2.flv', [self.portrait, self.round4])
|
| 228 |
+
app.GEOMETRY_SCAN = 'off'
|
| 229 |
+
try:
|
| 230 |
+
canvas, _ = app.plan_geometry(mixed, 'tester', 2)
|
| 231 |
+
finally:
|
| 232 |
+
app.GEOMETRY_SCAN = 'auto'
|
| 233 |
+
self.assertIsNone(canvas, 'GEOMETRY_SCAN=off يعيد السلوك القديم حرفيًا')
|
| 234 |
+
|
| 235 |
+
def test_plan_picks_dominant_canvas(self):
|
| 236 |
+
mixed = splice_same_source(self.dir / 'mixed3.flv',
|
| 237 |
+
[self.portrait, self.round4, self.portrait])
|
| 238 |
+
canvas, reason = app.plan_geometry(mixed, 'tester', 2)
|
| 239 |
+
self.assertEqual(canvas, (320, 568), 'الإطار يُختار على المقاس الغالب لا على أول مشهد')
|
| 240 |
+
self.assertIn('320×568', reason.replace('x', '×'))
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def tearDownModule():
|
| 244 |
+
shutil.rmtree(WORK, ignore_errors=True)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
if __name__ == '__main__':
|
| 248 |
+
unittest.main(verbosity=2, exit=False)
|
| 249 |
+
sys.exit(0)
|
variables.env
CHANGED
|
@@ -40,19 +40,15 @@ MIN_FREE_MB=1024
|
|
| 40 |
ENSURE_FREE_MB=2048
|
| 41 |
KEEP_LAST_FILES=2
|
| 42 |
|
| 43 |
-
# ----------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
YOUTUBE_DAILY_BUDGET=9000
|
| 45 |
-
YOUTUBE_VIDEO_MODE=original
|
| 46 |
-
YOUTUBE_SEND_MODE=copy
|
| 47 |
-
YOUTUBE_MAX_EVENTS=3
|
| 48 |
-
YOUTUBE_SCAN_SECONDS=30
|
| 49 |
-
YOUTUBE_END_GRACE=90
|
| 50 |
-
YOUTUBE_FORMAT_GUARD=off
|
| 51 |
-
YOUTUBE_FLAP_LIMIT=4
|
| 52 |
-
YOUTUBE_FLAP_FALLBACK_MODE=fixed720
|
| 53 |
-
YOUTUBE_FIFO_TIMESHIFT=10
|
| 54 |
-
YOUTUBE_HEALTH_INTERVAL=180
|
| 55 |
-
YOUTUBE_X264_PRESET=veryfast
|
| 56 |
-
YOUTUBE_CRF=22
|
| 57 |
-
YOUTUBE_RECOVERY_WAIT=1
|
| 58 |
TIKTOK_TT_TARGET_IDC=useast1a
|
|
|
|
| 40 |
ENSURE_FREE_MB=2048
|
| 41 |
KEEP_LAST_FILES=2
|
| 42 |
|
| 43 |
+
# ---------- إصلاح الجولات الرباعية (تغيّر مقاس الصورة وسط البث) ----------
|
| 44 |
+
# auto = نوحّد الإطار فقط إذا رصد المسجّل تغيّر إعداد الصورة (الافتراضي والأخف)
|
| 45 |
+
# on = نفحص كل تسجيل بالإضافة إلى ذلك | off = نُعطّل الإصلاح كليًا
|
| 46 |
+
GEOMETRY_SCAN=auto
|
| 47 |
+
GEOMETRY_FIX=normalize
|
| 48 |
+
GEOMETRY_PRESET=veryfast
|
| 49 |
+
GEOMETRY_CRF=20
|
| 50 |
+
GEOMETRY_PAD_COLOR=black
|
| 51 |
+
|
| 52 |
+
# ---------- يوتيوب (أرشفة فقط — البث المباشر ملغى) ----------
|
| 53 |
YOUTUBE_DAILY_BUDGET=9000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
TIKTOK_TT_TARGET_IDC=useast1a
|
verify.sh
DELETED
|
@@ -1,139 +0,0 @@
|
|
| 1 |
-
#!/bin/sh
|
| 2 |
-
# تحقّق بعد الرفع: تأكد أن ملفات السبيس صارت النسخة المُصلَحة فعلًا.
|
| 3 |
-
# شغّله داخل السبيس (Terminal) أو محليًا على المجلد الذي يحتوي الملفات.
|
| 4 |
-
set -e
|
| 5 |
-
DIR="${1:-.}"
|
| 6 |
-
cd "$DIR"
|
| 7 |
-
|
| 8 |
-
echo "== الملفات الموجودة =="
|
| 9 |
-
ls -1 Dockerfile flv_bridge.py relay.py youtube_manager.py
|
| 10 |
-
|
| 11 |
-
echo
|
| 12 |
-
echo "== 1) Dockerfile يحمل YOUTUBE_SEND_MODE =="
|
| 13 |
-
grep -q 'YOUTUBE_SEND_MODE=copy' Dockerfile && echo " OK" || { echo " FAIL — Dockerfile قديم"; exit 1; }
|
| 14 |
-
|
| 15 |
-
echo
|
| 16 |
-
echo "== 2) flv_bridge.py فيه FormatChanged =="
|
| 17 |
-
grep -q 'class FormatChanged' flv_bridge.py && echo " OK" || { echo " FAIL"; exit 1; }
|
| 18 |
-
grep -q '_video_signature' flv_bridge.py && echo " OK — فحص Sequence Header موجود" || { echo " FAIL"; exit 1; }
|
| 19 |
-
|
| 20 |
-
echo
|
| 21 |
-
echo "== 3) relay.py فيه send_mode / canvas / note_format_change =="
|
| 22 |
-
for token in 'def send_mode' 'def canvas' 'def note_format_change' 'except FormatChanged' 'YOUTUBE_SEND_MODE'; do
|
| 23 |
-
grep -q "$token" relay.py && echo " OK — $token" || { echo " FAIL — ناقص $token"; exit 1; }
|
| 24 |
-
done
|
| 25 |
-
|
| 26 |
-
echo
|
| 27 |
-
echo "== 4) youtube_manager.py فيه معالجة FormatChanged =="
|
| 28 |
-
grep -q 'from flv_bridge import FormatChanged' youtube_manager.py && echo " OK — import" || { echo " FAIL"; exit 1; }
|
| 29 |
-
grep -q 'except FormatChanged' youtube_manager.py && echo " OK — sender_loop" || { echo " FAIL"; exit 1; }
|
| 30 |
-
|
| 31 |
-
echo
|
| 32 |
-
echo "== 5) الملفات تُترجَم بدون أخطاء =="
|
| 33 |
-
python3 -m py_compile flv_bridge.py relay.py youtube_manager.py app.py && echo " OK"
|
| 34 |
-
|
| 35 |
-
echo
|
| 36 |
-
echo "== 6) السلوك الفعلي: الوضع الافتراضي = copy، والأوامر تُبنى صحيحًا =="
|
| 37 |
-
python3 - <<'PY'
|
| 38 |
-
import importlib.util, os, shutil, sys
|
| 39 |
-
def load(n, p):
|
| 40 |
-
s = importlib.util.spec_from_file_location(n, p); m = importlib.util.module_from_spec(s)
|
| 41 |
-
sys.modules[n] = m; s.loader.exec_module(m); return m
|
| 42 |
-
load('flv_bridge', 'flv_bridge.py'); rl = load('relay', 'relay.py')
|
| 43 |
-
r = rl.Relay(shutil.which('ffmpeg') or '/usr/bin/ffmpeg', lambda u: [], lambda u, m: None)
|
| 44 |
-
for mode in ('copy', 'fixed1080', 'fixed720'):
|
| 45 |
-
os.environ['YOUTUBE_SEND_MODE'] = mode
|
| 46 |
-
assert r.send_mode() == mode, mode
|
| 47 |
-
cmd = ' '.join(r.command('rtmps://a.rtmps.youtube.com:443/live2/KEY'))
|
| 48 |
-
if mode == 'copy':
|
| 49 |
-
assert '-c:v copy' in cmd and '-rtmp_enhanced_codecs hvc1' in cmd, cmd
|
| 50 |
-
assert 'libx264' not in cmd
|
| 51 |
-
else:
|
| 52 |
-
assert 'libx264' in cmd and 'pad=' in cmd and '-sc_threshold 0' in cmd, cmd
|
| 53 |
-
assert '-c:v copy' not in cmd
|
| 54 |
-
print(' OK — %-9s -> %s' % (mode, cmd[:110] + '...'))
|
| 55 |
-
os.environ.pop('YOUTUBE_SEND_MODE', None)
|
| 56 |
-
assert r.send_mode() == 'copy', 'default must stay copy'
|
| 57 |
-
print(' OK — بدون المتغير: الافتراضي copy (لا تغيير في السلوك)')
|
| 58 |
-
PY
|
| 59 |
-
|
| 60 |
-
echo
|
| 61 |
-
echo "== 7) اختبار حي: بث يغيّر مقاسه في المنتصف =="
|
| 62 |
-
python3 - <<'PY'
|
| 63 |
-
import importlib.util, os, shutil, struct, subprocess, sys
|
| 64 |
-
def load(n, p):
|
| 65 |
-
s = importlib.util.spec_from_file_location(n, p); m = importlib.util.module_from_spec(s)
|
| 66 |
-
sys.modules[n] = m; s.loader.exec_module(m); return m
|
| 67 |
-
fb = load('flv_bridge', 'flv_bridge.py')
|
| 68 |
-
ff = shutil.which('ffmpeg')
|
| 69 |
-
if not ff:
|
| 70 |
-
print(' SKIP — ffmpeg غير موجود'); sys.exit(0)
|
| 71 |
-
|
| 72 |
-
def enc(path, size, codec):
|
| 73 |
-
cmd = [ff, '-hide_banner', '-loglevel', 'error', '-y', '-f', 'lavfi',
|
| 74 |
-
'-i', 'testsrc2=size=%s:rate=30' % size, '-t', '3', '-c:v', codec]
|
| 75 |
-
if codec == 'libx265':
|
| 76 |
-
cmd += ['-x265-params', 'keyint=30:min-keyint=30:log-level=error', '-rtmp_enhanced_codecs', 'hvc1']
|
| 77 |
-
else:
|
| 78 |
-
cmd += ['-g', '30']
|
| 79 |
-
cmd += ['-f', 'flv', path]
|
| 80 |
-
subprocess.run(cmd, check=True)
|
| 81 |
-
|
| 82 |
-
def parse(p):
|
| 83 |
-
d = open(p, 'rb').read(); h = struct.unpack('>I', d[5:9])[0] + 4
|
| 84 |
-
pos = h; tags = []
|
| 85 |
-
while pos + 11 <= len(d):
|
| 86 |
-
t = d[pos]; sz = int.from_bytes(d[pos+1:pos+4], 'big')
|
| 87 |
-
ts = int.from_bytes(d[pos+4:pos+7], 'big') | (d[pos+7] << 24)
|
| 88 |
-
tags.append([t, ts, d[pos+11:pos+11+sz]]); pos += 11 + sz + 4
|
| 89 |
-
return d[:h], tags
|
| 90 |
-
|
| 91 |
-
def write(p, head, tags):
|
| 92 |
-
with open(p, 'wb') as f:
|
| 93 |
-
f.write(head)
|
| 94 |
-
for t, ts, b in tags:
|
| 95 |
-
f.write(bytes([t]) + len(b).to_bytes(3, 'big') + (ts & 0xFFFFFF).to_bytes(3, 'big')
|
| 96 |
-
+ bytes([(ts >> 24) & 0xFF]) + b'\0\0\0' + b + (11 + len(b)).to_bytes(4, 'big'))
|
| 97 |
-
|
| 98 |
-
for codec, ext in (('libx265', 'hevc'), ('libx264', 'h264')):
|
| 99 |
-
enc('/tmp/_a.flv', '1080x1920', codec); enc('/tmp/_b.flv', '720x1280', codec)
|
| 100 |
-
ha, ta = parse('/tmp/_a.flv'); _, tb = parse('/tmp/_b.flv')
|
| 101 |
-
base = max(x[1] for x in ta) + 34
|
| 102 |
-
merged = ta + [[t, ts + base, b] for t, ts, b in tb if t != 18]
|
| 103 |
-
write('/tmp/_sw.flv', ha, merged)
|
| 104 |
-
|
| 105 |
-
b = fb.FLVBridge()
|
| 106 |
-
raw = open('/tmp/_sw.flv', 'rb').read()
|
| 107 |
-
raised = False
|
| 108 |
-
try:
|
| 109 |
-
for i in range(0, len(raw), 65536):
|
| 110 |
-
b.feed(raw[i:i+65536])
|
| 111 |
-
except fb.FormatChanged:
|
| 112 |
-
raised = True
|
| 113 |
-
print(' %s: FormatChanged %s' % (ext.upper(), 'OK — رُصد التغيّر' if raised else 'FAIL'))
|
| 114 |
-
assert raised
|
| 115 |
-
|
| 116 |
-
# detection off (fixed canvas mode) must swallow it
|
| 117 |
-
b2 = fb.FLVBridge(detect_format_change=False); n = 0
|
| 118 |
-
for i in range(0, len(raw), 65536):
|
| 119 |
-
n += len(b2.feed(raw[i:i+65536]))
|
| 120 |
-
print(' %s: detect off -> emitted %d bytes, changes seen %d OK' % (ext.upper(), n, b2.format_changes))
|
| 121 |
-
PY
|
| 122 |
-
|
| 123 |
-
echo
|
| 124 |
-
echo "== 8) توكن تيك توك: الصيغ والأولويات وعدم التسريب =="
|
| 125 |
-
python3 verify_tokens.py || { echo " FAIL"; exit 1; }
|
| 126 |
-
|
| 127 |
-
echo
|
| 128 |
-
echo "== 9) كل طلبات تيك توك تمر عبر التوكن =="
|
| 129 |
-
COUNT=$(grep -c "tiktok_auth.headers(" app.py relay.py youtube_manager.py | awk -F: '{s+=$2} END {print s}')
|
| 130 |
-
echo " مواضع tiktok_auth.headers(): $COUNT (المتوقع 5)"
|
| 131 |
-
[ "$COUNT" -ge 5 ] || { echo " FAIL — يوجد طلب لا يحمل الجلسة"; exit 1; }
|
| 132 |
-
grep -q "tiktok_auth.py" Dockerfile && echo " OK — Dockerfile ينسخ tiktok_auth.py" || { echo " FAIL"; exit 1; }
|
| 133 |
-
grep -q "TIKTOK_SESSIONID" Dockerfile && echo " OK — Dockerfile يعلن متغيرات التوكن" || { echo " FAIL"; exit 1; }
|
| 134 |
-
grep -q "api/tiktok/token" app.py && echo " OK — نقاط API للتوكن موجودة" || { echo " FAIL"; exit 1; }
|
| 135 |
-
grep -q "AgeRestricted" app.py && echo " OK — كشف التقييد العمري موجود" || { echo " FAIL"; exit 1; }
|
| 136 |
-
grep -q "renderTiktok" index.html && echo " OK — لوحة التوكن في الواجهة" || { echo " FAIL"; exit 1; }
|
| 137 |
-
|
| 138 |
-
echo
|
| 139 |
-
echo "✅ كل الفحوصات نجحت — الملفات مُصلَحة وجاهزة."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
youtube_core.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""نواة يوتيوب بلا بثّ مباشر — V11.
|
| 2 |
+
|
| 3 |
+
أُلغي البثّ المباشر من المشروع نهائيًا: حُذفت `youtube_manager.py` (مدير الأحداث)
|
| 4 |
+
و`relay.py` (الدفع إلى RTMPS) و`flv_bridge.py` (جسر البثّ). لم يعد التطبيق ينشئ أي
|
| 5 |
+
`liveBroadcast` ولا `liveStream` ولا يفتح أي اتصال دفع إلى يوتيوب.
|
| 6 |
+
|
| 7 |
+
ما بقي هنا هو ما تحتاجه **الأرشفة** وحدها، ولا علاقة له بالبث المباشر:
|
| 8 |
+
• `api` — نقل Google API (OAuth refresh token + تقدير الكوتا).
|
| 9 |
+
• `journal` — السجل الدائم غير السرّي (القناة + ميزانية الكوتا اليومية).
|
| 10 |
+
• `check()` — فحص التفويض وقراءة القناة المرتبطة، ليعرف المشغّل أين تُرفع التسجيلات.
|
| 11 |
+
• مساعدتان نصّيتان للعنوان والوصف كانتا مشتركتين بين الأرشفة والمدير المحذوف.
|
| 12 |
+
|
| 13 |
+
لماذا بقي السجل نفسه ولم يُنشأ جديد: `youtube-api-state.json` يحمل ميزانية الكوتا
|
| 14 |
+
اليومية المشتركة، وحذفه كان سيعيد العدّاد إلى الصفر فيتجاوز المشروع حصة يوتيوب
|
| 15 |
+
الحقيقية ويفشل الرفع بـ403.
|
| 16 |
+
"""
|
| 17 |
+
import os, re, threading
|
| 18 |
+
|
| 19 |
+
from youtube_api import GoogleAPI, Journal, APIError
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class YouTubeCore:
|
| 23 |
+
def __init__(self, log, path):
|
| 24 |
+
self.log = log
|
| 25 |
+
self.journal = Journal(path)
|
| 26 |
+
self.api = GoogleAPI(self.journal)
|
| 27 |
+
self.operations = threading.RLock()
|
| 28 |
+
|
| 29 |
+
# ---------- مساعدات نصّية (تستعملها الأرشفة) ----------
|
| 30 |
+
@staticmethod
|
| 31 |
+
def extract_hashtags(text):
|
| 32 |
+
"""وسوم من عنوان البث نفسه، بلا تكرار، 12 كحدّ أقصى."""
|
| 33 |
+
seen = []
|
| 34 |
+
out = []
|
| 35 |
+
for tag in re.findall(r'#[^\s#]+', text or ''):
|
| 36 |
+
if tag not in seen:
|
| 37 |
+
seen.append(tag)
|
| 38 |
+
out.append(tag)
|
| 39 |
+
if len(out) >= 12:
|
| 40 |
+
break
|
| 41 |
+
return ' '.join(out)[:400]
|
| 42 |
+
|
| 43 |
+
@staticmethod
|
| 44 |
+
def description_with_links(text, user, hashtags):
|
| 45 |
+
"""تذييل خاص بكل مذيع: حسابه على تيك توك + وسوم بثّه، بلا خلط بين الحسابات."""
|
| 46 |
+
text = (text or '').rstrip()
|
| 47 |
+
link = f'https://www.tiktok.com/@{user}'
|
| 48 |
+
if link not in text:
|
| 49 |
+
text += f'\n\n🎵 حساب المذيع على تيك توك\n@{user}\n{link}'
|
| 50 |
+
if hashtags and hashtags not in text:
|
| 51 |
+
text += '\n\n' + hashtags
|
| 52 |
+
return re.sub(r'\n{3,}', '\n\n', text)
|
| 53 |
+
|
| 54 |
+
# ---------- القناة ----------
|
| 55 |
+
def check_channel(self):
|
| 56 |
+
result = self.api.call('channels', params={'part': 'id,snippet', 'mine': 'true'})
|
| 57 |
+
channels = result.get('items', [])
|
| 58 |
+
if len(channels) != 1:
|
| 59 |
+
raise RuntimeError('لم نجد قناة واحدة مفوضة؛ أعد OAuth واختر قناة يوتيوب الصحيحة')
|
| 60 |
+
channel = {'id': channels[0]['id'], 'title': channels[0]['snippet']['title']}
|
| 61 |
+
old = self.journal.read()['channel']
|
| 62 |
+
if old and old['id'] != channel['id']:
|
| 63 |
+
raise RuntimeError('القناة المفوضة تغيرت؛ توقف آمن لحماية أرشيف القناة السابقة')
|
| 64 |
+
self.journal.change(lambda d: d.update(channel=channel))
|
| 65 |
+
return channel
|
| 66 |
+
|
| 67 |
+
def check(self):
|
| 68 |
+
with self.operations:
|
| 69 |
+
return self.check_channel()
|
| 70 |
+
|
| 71 |
+
# ---------- حالة للّوحة ----------
|
| 72 |
+
def snapshot(self):
|
| 73 |
+
d = self.journal.read()
|
| 74 |
+
budget = d.get('budget') or {}
|
| 75 |
+
return {
|
| 76 |
+
'channel': d.get('channel'),
|
| 77 |
+
'configured': self.api.configured(),
|
| 78 |
+
'budget': budget,
|
| 79 |
+
'live_relay': 'removed', # البث المباشر محذوف من هذا الإصدار
|
| 80 |
+
'leftover_events': len(d.get('events') or {}),
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
# ---------- دورة حياة ----------
|
| 84 |
+
def restore(self):
|
| 85 |
+
"""إقلاع: لا يوجد ما يُستأنف. نُطفئ علم الأتمتة القديم ونُنبّه على بقايا الأحداث."""
|
| 86 |
+
d = self.journal.read()
|
| 87 |
+
if d.get('enabled'):
|
| 88 |
+
self.journal.change(lambda x: x.update(enabled=False))
|
| 89 |
+
self.log('system', 'أُلغي البث المباشر من المشروع؛ عُطّلت أتمتة الأحداث القديمة ولن يُنشأ أي حدث جديد')
|
| 90 |
+
leftover = d.get('events') or {}
|
| 91 |
+
if leftover:
|
| 92 |
+
self.log('system',
|
| 93 |
+
'تنبيه: %d حدث بث قديم ما زال مسجّلًا من النسخة السابقة — أنهِه يدويًا من استوديو يوتيوب '
|
| 94 |
+
'(التطبيق لم يعد يديره)' % len(leftover))
|
| 95 |
+
|
| 96 |
+
def stop(self):
|
| 97 |
+
"""لا خيوط ولا عمليات دفع لإيقافها بعد إلغاء البث المباشر."""
|
| 98 |
+
return None
|
youtube_manager.py
DELETED
|
@@ -1,620 +0,0 @@
|
|
| 1 |
-
"""One managed YouTube event per live TikTok session, several events at the same time.
|
| 2 |
-
Each event carries the name/title/hashtags of the TikTok account it relays, and each
|
| 3 |
-
account gets its own reusable ingestion stream so concurrent events never mix.
|
| 4 |
-
Fail closed on ambiguous remote creates."""
|
| 5 |
-
import os, re, threading, time, uuid
|
| 6 |
-
from datetime import datetime, timezone, timedelta
|
| 7 |
-
from zoneinfo import ZoneInfo
|
| 8 |
-
from urllib.parse import urlparse
|
| 9 |
-
from curl_cffi import requests
|
| 10 |
-
from relay import Sender
|
| 11 |
-
from flv_bridge import FormatChanged
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
class _SafeTemplate(dict):
|
| 15 |
-
"""قالب لا ينكسر: مفتاح غير معروف يصبح فراغًا بدل KeyError يمنع إنشاء الحدث.
|
| 16 |
-
|
| 17 |
-
نفس العقد المستعمل في youtube_archive.Safe — وهو سبب أن الأرشفة لم تتأثر أصلًا."""
|
| 18 |
-
def __missing__(self, key):
|
| 19 |
-
return ''
|
| 20 |
-
from youtube_api import GoogleAPI, Journal, APIError
|
| 21 |
-
|
| 22 |
-
class ManagedYouTube:
|
| 23 |
-
def __init__(self,ffmpeg,observe,log,accounts,path,headers_for=None):
|
| 24 |
-
self.ffmpeg=ffmpeg;self.observe=observe;self.log=log;self.accounts=accounts
|
| 25 |
-
self.headers_for=headers_for or (lambda user:{})
|
| 26 |
-
self.journal=Journal(path);self.api=GoogleAPI(self.journal)
|
| 27 |
-
self.operations=threading.RLock()
|
| 28 |
-
self.manager_thread=None;self.manager_stop=threading.Event()
|
| 29 |
-
self.senders={} # session_key -> {'thread','stop','sender'}
|
| 30 |
-
self.destinations={} # username -> rtmps url (in-memory only, never persisted)
|
| 31 |
-
self.scan=max(15,int(os.getenv('YOUTUBE_SCAN_SECONDS','30')))
|
| 32 |
-
self.end_grace=max(30,int(os.getenv('YOUTUBE_END_GRACE','90')))
|
| 33 |
-
self.max_events=max(1,int(os.getenv('YOUTUBE_MAX_EVENTS','3')))
|
| 34 |
-
self.last_logged=None;self.status_text='متوقف'
|
| 35 |
-
def marker(self):return 'QUSAI-AUTO-'+self.journal.read()['instance']
|
| 36 |
-
@staticmethod
|
| 37 |
-
def video_sources(sources):
|
| 38 |
-
mode=os.getenv('YOUTUBE_VIDEO_MODE','original').strip().lower()
|
| 39 |
-
if mode=='h264':return [s for s in sources if any(c in s['label'].lower() for c in ('h264','avc'))]
|
| 40 |
-
if mode!='original':raise RuntimeError('YOUTUBE_VIDEO_MODE يجب أن يكون original أو h264')
|
| 41 |
-
supported=[s for s in sources if any(c in s['label'].lower() for c in ('h264','avc','h265','hevc'))]
|
| 42 |
-
# Strict top source: no silent quality downgrade in original mode.
|
| 43 |
-
return supported[:1]
|
| 44 |
-
@staticmethod
|
| 45 |
-
def extract_hashtags(text):
|
| 46 |
-
"""Hashtags come from the TikTok live title itself, deduplicated, at most 12."""
|
| 47 |
-
seen=[];out=[]
|
| 48 |
-
for tag in re.findall(r'#[^\s#]+',text or ''):
|
| 49 |
-
if tag not in seen:seen.append(tag);out.append(tag)
|
| 50 |
-
if len(out)>=12:break
|
| 51 |
-
return ' '.join(out)[:400]
|
| 52 |
-
@staticmethod
|
| 53 |
-
def description_with_links(text,user,hashtags):
|
| 54 |
-
"""Per-account footer: the broadcaster's own TikTok profile + hashtags of this live.
|
| 55 |
-
No cross-account links, so concurrent events never mix attributions."""
|
| 56 |
-
text=text.rstrip()
|
| 57 |
-
link=f'https://www.tiktok.com/@{user}'
|
| 58 |
-
if link not in text:
|
| 59 |
-
text+=f'\n\n🎵 حساب المذيع على تيك توك\n@{user}\n{link}'
|
| 60 |
-
if hashtags and hashtags not in text:text+='\n\n'+hashtags
|
| 61 |
-
return re.sub(r'\n{3,}','\n\n',text)
|
| 62 |
-
|
| 63 |
-
# ---------- journal helpers ----------
|
| 64 |
-
def event_update(self,key,**kw):
|
| 65 |
-
def change(d):
|
| 66 |
-
ev=d['events'].get(key)
|
| 67 |
-
if ev is not None:ev.update(kw)
|
| 68 |
-
self.journal.change(change)
|
| 69 |
-
def event_note(self,key,text):self.event_update(key,note=text)
|
| 70 |
-
def streams_change(self,user,**kw):
|
| 71 |
-
def change(d):d['streams'].setdefault(user,{}).update(kw)
|
| 72 |
-
self.journal.change(change)
|
| 73 |
-
def set_status(self,text):self.status_text=text
|
| 74 |
-
@staticmethod
|
| 75 |
-
def safe_error(e):
|
| 76 |
-
if isinstance(e,(APIError,RuntimeError,ValueError)):return str(e)
|
| 77 |
-
# Keep the message generic (no raw network/traceback text can leak), but name the
|
| 78 |
-
# exception class. Without it a KeyError from a stale saved template is
|
| 79 |
-
# indistinguishable from any other internal fault and cannot be diagnosed.
|
| 80 |
-
return 'خطأ داخلي في إدارة يوتيوب؛ التوقف عن الإنشاء لحماية الأحداث ('+type(e).__name__+')'
|
| 81 |
-
def _event_error(self,key,e):
|
| 82 |
-
text=self.safe_error(e)
|
| 83 |
-
ev=self.journal.read()['events'].get(key) or {}
|
| 84 |
-
if text!=ev.get('note'):self.log(ev.get('username') or key,text)
|
| 85 |
-
self.event_note(key,text)
|
| 86 |
-
def _clear_stale_error_note(self,key):
|
| 87 |
-
"""FIX C: clear the sticky 'حماية الأحداث' / 'خطأ داخلي' note after recovery.
|
| 88 |
-
Previously _event_error wrote the note and nothing ever cleared it, so a transient
|
| 89 |
-
SSLError left the UI stuck for 40 min even though the stream was healthy."""
|
| 90 |
-
ev=self.journal.read()['events'].get(key)
|
| 91 |
-
if not ev:return
|
| 92 |
-
note=ev.get('note') or ''
|
| 93 |
-
if note and ('حماية الأحداث' in note or 'خطأ داخلي' in note or 'networkOrInvalidResponse' in note):
|
| 94 |
-
self.event_update(key, note='')
|
| 95 |
-
|
| 96 |
-
# ---------- channel / settings ----------
|
| 97 |
-
def check_channel(self):
|
| 98 |
-
result=self.api.call('channels',params={'part':'id,snippet','mine':'true'})
|
| 99 |
-
channels=result.get('items',[])
|
| 100 |
-
if len(channels)!=1:raise RuntimeError('لم نجد قناة واحدة مفوضة؛ أعد OAuth واختر قناة يوتيوب الصحيحة')
|
| 101 |
-
channel={'id':channels[0]['id'],'title':channels[0]['snippet']['title']}
|
| 102 |
-
old=self.journal.read()['channel']
|
| 103 |
-
if old and old['id']!=channel['id']:raise RuntimeError('القناة المفوضة تغيرت؛ توقف آمن لحماية أحداث القناة السابقة')
|
| 104 |
-
self.journal.change(lambda d:d.update(channel=channel))
|
| 105 |
-
return channel
|
| 106 |
-
def check(self):
|
| 107 |
-
with self.operations:return self.check_channel()
|
| 108 |
-
def settings(self,values):
|
| 109 |
-
with self.operations:
|
| 110 |
-
d=self.journal.read()
|
| 111 |
-
if d['enabled'] or d['events']:
|
| 112 |
-
raise ValueError('عطّل الأتمتة وانتظر إغلاق الأحداث قبل تعديل قوالب الأحداث')
|
| 113 |
-
clean={k:str(values.get(k,'')).strip() for k in ('name','privacy','title','description')}
|
| 114 |
-
if clean['privacy'] not in ('public','unlisted','private'):raise ValueError('خصوصية غير صالحة')
|
| 115 |
-
if len(clean['name'])>80:raise ValueError('الاسم الاحتياطي حتى 80 حرفًا (الاسم الفعلي يُجلب من حساب تيك توك)')
|
| 116 |
-
test={'name':'فلان','title':'بث تجريبي','hashtags':'#تجربة','date':'11-09-2026','time':'16:30:00','username':'example','room_id':'123',
|
| 117 |
-
'nickname':'فلان','live_title':'بث تجريبي','broadcaster':'فلان'}
|
| 118 |
-
try:title=clean['title'].format(**test);desc=clean['description'].format(**test)
|
| 119 |
-
except (KeyError,ValueError,IndexError,AttributeError):raise ValueError('متغير قالب غير مدعوم')
|
| 120 |
-
if not title or len(title)>100 or any(c in title+desc for c in '<>') or len(desc)>4500:
|
| 121 |
-
raise ValueError('العنوان الناتج حتى 100 حرف والوصف حتى 4500، وبدون < أو >')
|
| 122 |
-
# Prevent arbitrary attribute/index lookups in format templates.
|
| 123 |
-
import string
|
| 124 |
-
for template in (clean['title'],clean['description']):
|
| 125 |
-
for _,field,spec,conv in string.Formatter().parse(template):
|
| 126 |
-
if field is not None and (field not in test or spec or conv):raise ValueError('استخدم متغيرات القالب البسيطة فقط')
|
| 127 |
-
self.journal.change(lambda x:x.update(settings=clean))
|
| 128 |
-
def snapshot(self):
|
| 129 |
-
d=self.journal.read()
|
| 130 |
-
events=[]
|
| 131 |
-
for key,ev in d['events'].items():
|
| 132 |
-
entry=self.senders.get(key)
|
| 133 |
-
s=entry['sender'].snapshot() if entry and entry['thread'].is_alive() else {}
|
| 134 |
-
events.append({'username':ev['username'],'nickname':ev.get('nickname',''),'room_id':ev['room_id'],
|
| 135 |
-
'title':ev['title'],'phase':ev.get('phase'),'youtube_status':ev.get('youtube_status'),
|
| 136 |
-
'closing':bool(ev.get('closing')),'broadcast_id':ev['broadcast_id'],'note':ev.get('note',''),
|
| 137 |
-
'status':s.get('status',''),'source':s.get('source',''),'output_seconds':s.get('output_seconds',0),
|
| 138 |
-
# YouTube's own verdict on the inbound stream, so the UI can show why
|
| 139 |
-
# viewers buffer instead of us guessing at the cause.
|
| 140 |
-
'youtube_health':ev.get('youtube_health',''),'youtube_issues':ev.get('youtube_issues','')})
|
| 141 |
-
# Effective ingestion mode, including the automatic anti-flap fallback, so the
|
| 142 |
-
# UI can show whether the constant-canvas protection has kicked in.
|
| 143 |
-
try:
|
| 144 |
-
probe_sender=Sender(self.ffmpeg,self.log,self.headers_for)
|
| 145 |
-
send_mode=probe_sender.send_mode()
|
| 146 |
-
except Exception:
|
| 147 |
-
send_mode=os.getenv('YOUTUBE_SEND_MODE','copy')
|
| 148 |
-
return {'status':self.status_text,'automatic':d['enabled'],'events':events,
|
| 149 |
-
'video_mode':os.getenv('YOUTUBE_VIDEO_MODE','original'),'configured':self.api.configured(),
|
| 150 |
-
'send_mode':send_mode,
|
| 151 |
-
'watched_accounts':list(self.accounts()),'channel':d['channel'],'settings':d['settings'],
|
| 152 |
-
'budget':d['budget'],'history_count':len(d['history']),'max_events':self.max_events}
|
| 153 |
-
|
| 154 |
-
# ---------- enable / disable / lifecycle ----------
|
| 155 |
-
def enable(self,expected_channel):
|
| 156 |
-
if os.getenv('YOUTUBE_VIDEO_MODE','original').strip().lower() not in ('original','h264'):raise ValueError('YOUTUBE_VIDEO_MODE يجب أن يكون original أو h264')
|
| 157 |
-
with self.operations:
|
| 158 |
-
if not self.api.configured():raise ValueError('أسرار OAuth الثلاثة غير مكتملة')
|
| 159 |
-
channel=self.check_channel()
|
| 160 |
-
if not expected_channel or expected_channel!=channel['id']:raise ValueError('افحص الربط وأكّد القناة الصحيحة قبل التفعيل')
|
| 161 |
-
if any(e.get('closing') for e in self.journal.read()['events'].values()):
|
| 162 |
-
raise ValueError('توجد أحداث قيد الإنهاء؛ انتظر اكتمال تنظيفها')
|
| 163 |
-
self.journal.change(lambda d:d.update(enabled=True));self.launch()
|
| 164 |
-
def disable(self):
|
| 165 |
-
with self.operations:
|
| 166 |
-
def change(d):
|
| 167 |
-
d['enabled']=False
|
| 168 |
-
for ev in d['events'].values():ev['closing']=True
|
| 169 |
-
self.journal.change(change)
|
| 170 |
-
for key in list(self.senders):self.stop_sender(key)
|
| 171 |
-
self.launch()
|
| 172 |
-
self.set_status('تعطيل الأتمتة وإنهاء الأحداث الجارية')
|
| 173 |
-
def launch(self):
|
| 174 |
-
if self.manager_thread and self.manager_thread.is_alive():return
|
| 175 |
-
self.manager_stop=threading.Event()
|
| 176 |
-
self.manager_thread=threading.Thread(target=self.run_manager,daemon=True);self.manager_thread.start()
|
| 177 |
-
def restore(self):
|
| 178 |
-
d=self.journal.read()
|
| 179 |
-
if d['enabled'] or d['events']:
|
| 180 |
-
self.launch()
|
| 181 |
-
def stop(self):
|
| 182 |
-
# Process shutdown is not a user-requested end. Preserve remote events for restart recovery.
|
| 183 |
-
self.manager_stop.set()
|
| 184 |
-
for key in list(self.senders):self.stop_sender(key)
|
| 185 |
-
|
| 186 |
-
# ---------- remote helpers ----------
|
| 187 |
-
def remote(self,resource,rid):
|
| 188 |
-
items=self.api.call(resource,params={'part':'id,snippet,status,contentDetails'+(',cdn' if resource=='liveStreams' else ''),'id':rid}).get('items',[])
|
| 189 |
-
return items[0] if items else None
|
| 190 |
-
def reconcile(self,resource,marker):
|
| 191 |
-
params={'part':'id,snippet,status,contentDetails','mine':'true','maxResults':50}
|
| 192 |
-
if resource=='liveBroadcasts':
|
| 193 |
-
params.pop('mine');params.update(broadcastStatus='all',broadcastType='all')
|
| 194 |
-
items=self.api.items(resource,params)
|
| 195 |
-
matches=[i for i in items if marker in i.get('snippet',{}).get('description','').splitlines()]
|
| 196 |
-
if len(matches)>1:raise RuntimeError('أحداث متكررة بنفس علامة التطبيق؛ توقف للمراجعة دون حذف تلقائي')
|
| 197 |
-
return matches[0] if matches else None
|
| 198 |
-
|
| 199 |
-
# ---------- per-account ingestion streams ----------
|
| 200 |
-
def stream_markers(self,user,legacy):
|
| 201 |
-
markers=[self.marker()+'-STREAM-'+user]
|
| 202 |
-
if legacy:markers.append(self.marker()+'-STREAM') # stream migrated from V6/V7
|
| 203 |
-
return markers
|
| 204 |
-
def ensure_stream(self,user):
|
| 205 |
-
d=self.journal.read();st=d['streams'].get(user) or {}
|
| 206 |
-
markers=self.stream_markers(user,bool(st.get('legacy')))
|
| 207 |
-
if not st.get('id'):
|
| 208 |
-
if st.get('pending'):
|
| 209 |
-
found=None
|
| 210 |
-
for m in markers:
|
| 211 |
-
found=self.reconcile('liveStreams',m)
|
| 212 |
-
if found:break
|
| 213 |
-
if not found:raise RuntimeError('إنشاء مصدر سابق غير محسوم؛ ننتظر المصالحة بدل إنشاء مفتاح مكرر')
|
| 214 |
-
self.streams_change(user,id=found['id'],pending=False)
|
| 215 |
-
else:
|
| 216 |
-
self.streams_change(user,pending=True)
|
| 217 |
-
try:
|
| 218 |
-
r=self.api.call('liveStreams','POST',{'part':'snippet,cdn,contentDetails'},
|
| 219 |
-
{'snippet':{'title':user+' — مصدر بث التطبيق القابل لإعادة الاستخدام','description':markers[0]},
|
| 220 |
-
'cdn':{'ingestionType':'rtmp','resolution':'variable','frameRate':'variable'},'contentDetails':{'isReusable':True}})
|
| 221 |
-
except APIError as e:
|
| 222 |
-
if e.status and 400<=e.status<500:self.streams_change(user,pending=False)
|
| 223 |
-
raise
|
| 224 |
-
if not r.get('id'):raise RuntimeError('استجابة إنشاء المصدر غير محسومة؛ لن نكرر الإنشاء')
|
| 225 |
-
self.streams_change(user,id=r['id'],pending=False)
|
| 226 |
-
stream=self.remote('liveStreams',self.journal.read()['streams'][user]['id'])
|
| 227 |
-
if not stream or not any(m in stream.get('snippet',{}).get('description','').splitlines() for m in markers):
|
| 228 |
-
raise RuntimeError('مصدر التطبيق مفقود أو هويته تغيرت؛ لا ننشئ مفاتيح بديلة تلقائيًا')
|
| 229 |
-
ingest=stream.get('cdn',{}).get('ingestionInfo',{});key=ingest.get('streamName')
|
| 230 |
-
secure=ingest.get('rtmpsIngestionAddress')
|
| 231 |
-
if not secure:
|
| 232 |
-
# YouTube's secure endpoint; streamName remains in memory only.
|
| 233 |
-
secure='rtmps://a.rtmps.youtube.com:443/live2'
|
| 234 |
-
p=urlparse(secure)
|
| 235 |
-
if p.scheme!='rtmps' or not p.hostname or not p.hostname.endswith('.youtube.com') or not key:
|
| 236 |
-
raise RuntimeError('لم يُرجع يوتيوب عنوان إرسال آمنًا صالحًا')
|
| 237 |
-
self.destinations[user]=secure.rstrip('/')+'/'+key
|
| 238 |
-
return stream
|
| 239 |
-
|
| 240 |
-
# ---------- events ----------
|
| 241 |
-
def open_event(self,user,observation):
|
| 242 |
-
d=self.journal.read();key=user+':'+observation['room_id']
|
| 243 |
-
if key in d['events'] or any(h['session_key']==key for h in d['history']):return False
|
| 244 |
-
dt=datetime.now(ZoneInfo('Asia/Baghdad'));settings=d['settings']
|
| 245 |
-
nickname=str(observation.get('nickname') or '').strip()
|
| 246 |
-
live_title=str(observation.get('live_title') or '').strip()
|
| 247 |
-
name=nickname or user
|
| 248 |
-
title_value=live_title or (name+' — بث مباشر')
|
| 249 |
-
hashtags=self.extract_hashtags(live_title)
|
| 250 |
-
values={'name':name,'title':title_value,'hashtags':hashtags,'date':dt.strftime('%d-%m-%Y'),
|
| 251 |
-
'time':dt.strftime('%H:%M:%S'),'username':user,'room_id':observation['room_id'],
|
| 252 |
-
# Legacy aliases. Journals written by older builds keep their saved
|
| 253 |
-
# templates, and those used {nickname}/{live_title}. Without these the
|
| 254 |
-
# format() below raises KeyError, which safe_error() masks into a generic
|
| 255 |
-
# "internal error" — so no event is ever created and nothing goes to air.
|
| 256 |
-
# broadcaster: القوالب المحفوظة من بناء relay القديم تستعمله، وغيابه هنا كان
|
| 257 |
-
# يرفع KeyError يبتلعه safe_error فيصير «خطأ داخلي… لحماية الأحداث» ولا يُنشأ
|
| 258 |
-
# أي حدث ولا يصعد شيء للهواء. مقيس على هذا الـSpace: القالب المحفوظ هو
|
| 259 |
-
# «{broadcaster} | {date} {time}».
|
| 260 |
-
'nickname':name,'live_title':live_title,'broadcaster':name}
|
| 261 |
-
# format_map مع قاموس لا ينكسر: نفس ما تفعله وحدة الأرشفة (class Safe). قالب قديم
|
| 262 |
-
# بمتغيّر لم يعد مدعومًا يفقد ذلك المتغيّر فقط، ولا يمنع البث من الصعود.
|
| 263 |
-
values=_SafeTemplate(values)
|
| 264 |
-
title=settings['title'].format_map(values)[:100];marker=self.marker()+'-EVENT-'+uuid.uuid4().hex
|
| 265 |
-
desc=self.description_with_links(settings['description'].format_map(values)[:4400],user,hashtags)+'\n\n'+marker
|
| 266 |
-
ev={'username':user,'room_id':observation['room_id'],'session_key':key,'marker':marker,
|
| 267 |
-
'nickname':name,'live_title':live_title,'title':title,'description':desc,
|
| 268 |
-
'privacy':settings['privacy'],'created_at':time.time(),'note':'',
|
| 269 |
-
'broadcast_id':None,'create_pending':False,'phase':'planned','bound':False,'was_live':False,
|
| 270 |
-
'closing':False,'last_poll':0,'offline_since':None,'offline_count':0}
|
| 271 |
-
def change(d):d['events'][key]=ev
|
| 272 |
-
self.journal.change(change);return True
|
| 273 |
-
def ensure_broadcast(self,key):
|
| 274 |
-
ev=self.journal.read()['events'][key]
|
| 275 |
-
if not ev['broadcast_id']:
|
| 276 |
-
if ev['create_pending']:
|
| 277 |
-
found=self.reconcile('liveBroadcasts',ev['marker'])
|
| 278 |
-
if not found:raise RuntimeError('إنشاء حدث سابق غير محسوم؛ لا نكرر POST لمنع التراكم. يلزم مراجعة إذا استمر')
|
| 279 |
-
self.event_update(key,broadcast_id=found['id'],create_pending=False,phase='created')
|
| 280 |
-
else:
|
| 281 |
-
self.event_update(key,create_pending=True,phase='creating')
|
| 282 |
-
body={'snippet':{'title':ev['title'],'description':ev['description'],
|
| 283 |
-
'scheduledStartTime':(datetime.now(timezone.utc)+timedelta(seconds=30)).isoformat()},
|
| 284 |
-
'status':{'privacyStatus':ev['privacy']},
|
| 285 |
-
'contentDetails':{'enableAutoStart':False,'enableAutoStop':False,'enableDvr':True,
|
| 286 |
-
'recordFromStart':True,'monitorStream':{'enableMonitorStream':False}}}
|
| 287 |
-
try:r=self.api.call('liveBroadcasts','POST',{'part':'snippet,status,contentDetails'},body)
|
| 288 |
-
except APIError as e:
|
| 289 |
-
if e.status and 400<=e.status<500:self.event_update(key,create_pending=False,phase='planned')
|
| 290 |
-
raise
|
| 291 |
-
if not r.get('id'):raise RuntimeError('استجابة إنشاء الحدث غير محسومة')
|
| 292 |
-
self.event_update(key,broadcast_id=r['id'],create_pending=False,phase='created')
|
| 293 |
-
ev=self.journal.read()['events'][key];remote=self.remote('liveBroadcasts',ev['broadcast_id'])
|
| 294 |
-
if not remote:return None
|
| 295 |
-
if ev['marker'] not in remote.get('snippet',{}).get('description','').splitlines():
|
| 296 |
-
raise RuntimeError('علامة ملكية حدث التطبيق تغيرت؛ توقف آمن دون تعديل الحدث')
|
| 297 |
-
if remote.get('status',{}).get('privacyStatus')!=ev['privacy']:
|
| 298 |
-
raise RuntimeError('خصوصية الحدث في يوتيوب لا تطابق المطلوبة؛ راجع قيود مشروع API قبل النشر')
|
| 299 |
-
if remote.get('status',{}).get('lifeCycleStatus') not in ('complete','revoked') and not ev['bound']:
|
| 300 |
-
stream_id=(self.journal.read()['streams'].get(ev['username']) or {}).get('id')
|
| 301 |
-
if remote.get('contentDetails',{}).get('boundStreamId')!=stream_id:
|
| 302 |
-
self.api.call('liveBroadcasts/bind','POST',{'part':'id,contentDetails','id':ev['broadcast_id'],'streamId':stream_id})
|
| 303 |
-
self.event_update(key,bound=True,phase='bound')
|
| 304 |
-
return remote
|
| 305 |
-
def archive_event(self,key,outcome):
|
| 306 |
-
def change(d):
|
| 307 |
-
ev=d['events'].pop(key,None)
|
| 308 |
-
if ev is None:return
|
| 309 |
-
d['history'].append({'session_key':ev['session_key'],'broadcast_id':ev['broadcast_id'],
|
| 310 |
-
'title':ev['title'],'username':ev['username'],'nickname':ev.get('nickname','')})
|
| 311 |
-
d['history'][-1].update(outcome=outcome,ended_at=time.time())
|
| 312 |
-
self.journal.change(change)
|
| 313 |
-
self.stop_sender(key)
|
| 314 |
-
def finish_event(self,key):
|
| 315 |
-
ev=self.journal.read()['events'].get(key)
|
| 316 |
-
if not ev:return True
|
| 317 |
-
self.event_update(key,closing=True,phase='closing')
|
| 318 |
-
if not self.stop_sender(key):return False
|
| 319 |
-
ev=self.journal.read()['events'].get(key)
|
| 320 |
-
if not ev:return True
|
| 321 |
-
if not ev['broadcast_id']:
|
| 322 |
-
if ev['create_pending']:
|
| 323 |
-
found=self.reconcile('liveBroadcasts',ev['marker'])
|
| 324 |
-
if not found:raise RuntimeError('حدث غير محسوم عند الإنهاء؛ لا ننشئ أو نحذف بالتخمين')
|
| 325 |
-
self.event_update(key,broadcast_id=found['id'],create_pending=False)
|
| 326 |
-
else:self.archive_event(key,'cancelled_before_create');return True
|
| 327 |
-
ev=self.journal.read()['events'][key]
|
| 328 |
-
remote=self.remote('liveBroadcasts',ev['broadcast_id'])
|
| 329 |
-
if not remote:self.archive_event(key,'missing');return True
|
| 330 |
-
if ev['marker'] not in remote.get('snippet',{}).get('description','').splitlines():
|
| 331 |
-
raise RuntimeError('لا يمكن إنهاء حدث لا يحمل علامة هذه الجلسة')
|
| 332 |
-
status=remote.get('status',{}).get('lifeCycleStatus')
|
| 333 |
-
if status in ('complete','revoked'):
|
| 334 |
-
self.archive_event(key,status);return True
|
| 335 |
-
if status in ('live','testing'):
|
| 336 |
-
self.api.call('liveBroadcasts/transition','POST',{'part':'id,status','id':ev['broadcast_id'],'broadcastStatus':'complete'})
|
| 337 |
-
self.event_note(key,'طلب إنهاء الحدث أُرسل؛ التحقق من اكتماله');return False
|
| 338 |
-
# Never delete an event which actually started, even if local state missed the transition.
|
| 339 |
-
if status in ('created','ready') and not ev['was_live'] and not remote.get('snippet',{}).get('actualStartTime'):
|
| 340 |
-
self.api.call('liveBroadcasts','DELETE',{'id':ev['broadcast_id']})
|
| 341 |
-
self.archive_event(key,'empty_deleted');return True
|
| 342 |
-
self.event_note(key,'انتظار اكتمال انتقال حالة الحدث قبل إنهائه');return False
|
| 343 |
-
|
| 344 |
-
# ---------- senders ----------
|
| 345 |
-
def start_sender(self,key):
|
| 346 |
-
# FIX B: guard against 187 storm — don't push to RTMPS before YouTube ingest exists
|
| 347 |
-
# Previously called unconditionally from tick()/drive_event before broadcast was bound/ready,
|
| 348 |
-
# causing 7 ffmpeg failures in 5 min and 5:22 late start (~116s waste).
|
| 349 |
-
entry=self.senders.get(key)
|
| 350 |
-
if entry and entry['thread'].is_alive():return
|
| 351 |
-
ev=self.journal.read()['events'].get(key)
|
| 352 |
-
if not ev or ev.get('closing'):return
|
| 353 |
-
# Must be bound and have a destination; otherwise RTMPS will reject with 187
|
| 354 |
-
if not ev.get('bound') or not ev.get('broadcast_id'):
|
| 355 |
-
return
|
| 356 |
-
if ev.get('youtube_status') in ('complete','revoked','created'):
|
| 357 |
-
return
|
| 358 |
-
destination=self.destinations.get(ev['username'])
|
| 359 |
-
if not destination:raise RuntimeError('عنوان إرسال الحدث غير مهيأ')
|
| 360 |
-
stop=threading.Event();sender=Sender(self.ffmpeg,self.log,self.headers_for)
|
| 361 |
-
thread=threading.Thread(target=self.sender_loop,
|
| 362 |
-
args=(key,ev['username'],ev['room_id'],destination,stop,sender),daemon=True)
|
| 363 |
-
self.senders[key]={'thread':thread,'stop':stop,'sender':sender}
|
| 364 |
-
thread.start()
|
| 365 |
-
def stop_sender(self,key):
|
| 366 |
-
entry=self.senders.get(key)
|
| 367 |
-
if not entry:return True
|
| 368 |
-
entry['stop'].set()
|
| 369 |
-
entry['sender'].terminate()
|
| 370 |
-
if entry['thread'].is_alive():
|
| 371 |
-
entry['thread'].join(timeout=3)
|
| 372 |
-
if entry['thread'].is_alive():return False
|
| 373 |
-
self.senders.pop(key,None)
|
| 374 |
-
return True
|
| 375 |
-
def sender_loop(self,key,user,room_id,destination,stop,sender):
|
| 376 |
-
restart_at=0.0
|
| 377 |
-
while not stop.is_set():
|
| 378 |
-
try:
|
| 379 |
-
obs=self.observe(user)
|
| 380 |
-
if obs['status']!='live' or obs['room_id']!=room_id:return
|
| 381 |
-
sources=self.video_sources(obs['sources'])
|
| 382 |
-
for candidate in sources:
|
| 383 |
-
if stop.is_set():return
|
| 384 |
-
try:
|
| 385 |
-
if sender.send(user,candidate,destination,stop):break
|
| 386 |
-
except FormatChanged:
|
| 387 |
-
# Source resolution/layout changed (PK round started or ended, app
|
| 388 |
-
# resumed). Reconnect straight away so YouTube re-detects the new size.
|
| 389 |
-
sender.note_format_change()
|
| 390 |
-
self.log(user,'تغيرت أبعاد صورة المصدر أثناء البث؛ إعادة الاتصال فورًا بمقاس جديد')
|
| 391 |
-
restart_at=time.monotonic();break
|
| 392 |
-
except Exception:self.log(user,'تعثر إرسال الفيديو؛ نفس الحدث سيُستخدم')
|
| 393 |
-
except Exception:self.log(user,'تعذر تحديث مصدر الإرسال؛ لا ننشئ حدثًا إضافيًا')
|
| 394 |
-
# Skip the idle wait right after a resolution change; otherwise keep polling.
|
| 395 |
-
if restart_at and time.monotonic()-restart_at<30:restart_at=0.0;continue
|
| 396 |
-
stop.wait(15)
|
| 397 |
-
def probe(self,user,candidate):
|
| 398 |
-
# Check a readable FLV header before creating any public scheduled event.
|
| 399 |
-
r=requests.get(candidate['url'],impersonate='chrome',
|
| 400 |
-
headers={'Referer':'https://www.tiktok.com/',**self.headers_for(user)},stream=True,timeout=20)
|
| 401 |
-
try:
|
| 402 |
-
r.raise_for_status();prefix=b''
|
| 403 |
-
for chunk in r.iter_content():
|
| 404 |
-
prefix+=chunk
|
| 405 |
-
if len(prefix)>=3:return prefix[:3]==b'FLV'
|
| 406 |
-
return False
|
| 407 |
-
finally:r.close()
|
| 408 |
-
def _probe_ok(self,user,candidate):
|
| 409 |
-
try:return self.probe(user,candidate)
|
| 410 |
-
except Exception:return False
|
| 411 |
-
|
| 412 |
-
# ---------- main loop ----------
|
| 413 |
-
def tick(self):
|
| 414 |
-
d=self.journal.read()
|
| 415 |
-
accounts=list(self.accounts())
|
| 416 |
-
# 1) Close events that must close (user stop, disable, account removed).
|
| 417 |
-
for key,ev in list(d['events'].items()):
|
| 418 |
-
if ev.get('closing') or not d['enabled'] or ev['username'] not in accounts:
|
| 419 |
-
try:self.finish_event(key)
|
| 420 |
-
except Exception as e:self._event_error(key,e)
|
| 421 |
-
d=self.journal.read()
|
| 422 |
-
if not d['enabled']:
|
| 423 |
-
self.set_status('تعطيل الأتمتة وإنهاء الأحداث الجارية' if d['events'] else 'الأتمتة معطلة')
|
| 424 |
-
return
|
| 425 |
-
# 2) Discover new live sessions, one event per account, up to the concurrency cap.
|
| 426 |
-
cap=self.max_events;active=len(d['events']);capped=False
|
| 427 |
-
for user in accounts:
|
| 428 |
-
if active>=cap:capped=True;break
|
| 429 |
-
if any(e['username']==user for e in d['events'].values()):continue
|
| 430 |
-
try:obs=self.observe(user)
|
| 431 |
-
except Exception:continue
|
| 432 |
-
if obs['status']!='live':continue
|
| 433 |
-
key=user+':'+obs['room_id']
|
| 434 |
-
if key in d['events'] or any(h['session_key']==key for h in d['history']):continue
|
| 435 |
-
candidates=self.video_sources(obs['sources'])
|
| 436 |
-
if not candidates or not any(self._probe_ok(user,c) for c in candidates):continue
|
| 437 |
-
try:
|
| 438 |
-
self.ensure_stream(user)
|
| 439 |
-
if self.open_event(user,obs):active+=1
|
| 440 |
-
except Exception as e:self.log(user,self.safe_error(e))
|
| 441 |
-
# 3) Drive every open event independently.
|
| 442 |
-
for key in list(self.journal.read()['events'].keys()):
|
| 443 |
-
try:self.drive_event(key)
|
| 444 |
-
except Exception as e:self._event_error(key,e)
|
| 445 |
-
d=self.journal.read()
|
| 446 |
-
if capped:self.set_status('بلغنا الحد الأقصى للأحداث المتزامنة ('+str(cap)+')؛ حسابات إضافية تنتظر فراغها')
|
| 447 |
-
elif d['events']:self.set_status(str(len(d['events']))+' حدث/أحداث يوتيوب جارية')
|
| 448 |
-
else:self.set_status('انتظار بث قابل للإرسال من الحسابات المفعلة')
|
| 449 |
-
def drive_event(self,key):
|
| 450 |
-
ev=self.journal.read()['events'].get(key)
|
| 451 |
-
if not ev or ev.get('closing'):return
|
| 452 |
-
user=ev['username']
|
| 453 |
-
obs=self.observe(user)
|
| 454 |
-
if obs['status']=='offline':
|
| 455 |
-
self.stop_sender(key)
|
| 456 |
-
ev=self.journal.read()['events'].get(key)
|
| 457 |
-
if not ev:return
|
| 458 |
-
count=int(ev.get('offline_count') or 0)+1;since=ev.get('offline_since') or time.time()
|
| 459 |
-
self.event_update(key,offline_count=count,offline_since=since)
|
| 460 |
-
if count>=2 and time.time()-float(since)>=self.end_grace:self.finish_event(key)
|
| 461 |
-
else:self.event_note(key,'تأكيد انتهاء جلسة تيك توك؛ مهلة قبل إنهاء يوتيوب')
|
| 462 |
-
return
|
| 463 |
-
if obs['status']!='live':raise RuntimeError('حالة تيك توك غير مؤكدة؛ لا ننهي الحدث بسبب خطأ رصد')
|
| 464 |
-
if obs['room_id']!=ev['room_id']:
|
| 465 |
-
self.event_update(key,closing=True);self.finish_event(key);return
|
| 466 |
-
self.event_update(key,offline_since=None,offline_count=0)
|
| 467 |
-
# FIX C: clear sticky transient error note once TikTok is reachable again (recovery)
|
| 468 |
-
# Without this the 'التوقف عن الإنشاء لحماية الأحداث' message stayed 40 min.
|
| 469 |
-
if ev.get('note') and ('حماية الأحداث' in ev['note'] or 'خطأ داخلي' in ev['note'] or 'networkOrInvalidResponse' in ev['note']):
|
| 470 |
-
self._clear_stale_error_note(key)
|
| 471 |
-
ev=self.journal.read()['events'].get(key)
|
| 472 |
-
if not ev:return
|
| 473 |
-
if not self.video_sources(obs['sources']):
|
| 474 |
-
if not ev['was_live'] and time.time()-ev['created_at']>int(os.getenv('YOUTUBE_START_TIMEOUT','900')):
|
| 475 |
-
self.event_update(key,closing=True);self.finish_event(key)
|
| 476 |
-
else:self.event_note(key,'الجلسة مستمرة؛ انتظار المصدر المحدد للجودة')
|
| 477 |
-
return
|
| 478 |
-
if user not in self.destinations:
|
| 479 |
-
self.ensure_stream(user)
|
| 480 |
-
# recovery: stream succeeded after transient failure -> clear sticky note
|
| 481 |
-
ev=self.journal.read()['events'].get(key)
|
| 482 |
-
if ev and ev.get('note') and ('حماية الأحداث' in ev['note'] or 'خطأ داخلي' in ev['note']):
|
| 483 |
-
self._clear_stale_error_note(key)
|
| 484 |
-
ev=self.journal.read()['events'].get(key)
|
| 485 |
-
if not ev.get('bound'):
|
| 486 |
-
remote=self.ensure_broadcast(key)
|
| 487 |
-
if not remote:self.archive_event(key,'missing');return
|
| 488 |
-
ev=self.journal.read()['events'].get(key)
|
| 489 |
-
if not ev or not ev.get('bound'):
|
| 490 |
-
return
|
| 491 |
-
# FIX C: successful bind after transient TLS failure should clear the stuck note
|
| 492 |
-
if ev.get('note') and ('حماية الأحداث' in ev['note'] or 'خطأ داخلي' in ev['note']):
|
| 493 |
-
self._clear_stale_error_note(key)
|
| 494 |
-
ev=self.journal.read()['events'].get(key)
|
| 495 |
-
# FIX B: do NOT start sender here unconditionally. Fall through to gated check below
|
| 496 |
-
# which verifies YouTube lifecycle is ready/testing/live. This prevents the 187 storm
|
| 497 |
-
# that was caused by pushing to RTMPS before ingest existed (5:22 late, 116s waste).
|
| 498 |
-
|
| 499 |
-
# ---------- gated sender start (FIX B) ----------
|
| 500 |
-
ev=self.journal.read()['events'].get(key)
|
| 501 |
-
if not ev or not ev.get('broadcast_id') or not ev.get('bound'):
|
| 502 |
-
return
|
| 503 |
-
# Respect 45s poll interval for quota, but also gate sender on lifecycle status
|
| 504 |
-
# If we have a cached youtube_status we can gate without an extra API call
|
| 505 |
-
needs_poll = time.time()-float(ev.get('last_poll') or 0) >= 45
|
| 506 |
-
if needs_poll:
|
| 507 |
-
self.event_update(key,last_poll=time.time())
|
| 508 |
-
remote=self.remote('liveBroadcasts',ev['broadcast_id'])
|
| 509 |
-
if not remote:
|
| 510 |
-
self.event_update(key,closing=True);self.finish_event(key);return
|
| 511 |
-
if ev['marker'] not in remote.get('snippet',{}).get('description','').splitlines():
|
| 512 |
-
raise RuntimeError('تغيرت علامة الحدث؛ توقف آمن دون تعديل أحداث أخرى')
|
| 513 |
-
life=remote.get('status',{}).get('lifeCycleStatus');self.event_update(key,youtube_status=life)
|
| 514 |
-
ev=self.journal.read()['events'].get(key)
|
| 515 |
-
# FIX C: successful remote fetch means transport recovered -> clear stuck error
|
| 516 |
-
if ev.get('note') and ('حماية الأحداث' in ev['note'] or 'خطأ داخلي' in ev['note'] or 'network' in ev['note']):
|
| 517 |
-
self._clear_stale_error_note(key)
|
| 518 |
-
ev=self.journal.read()['events'].get(key)
|
| 519 |
-
else:
|
| 520 |
-
# Use cached status to gate sender without burning quota
|
| 521 |
-
life=ev.get('youtube_status')
|
| 522 |
-
# If status unknown or stale 'created', fetch once to avoid wrong gate
|
| 523 |
-
if not life or life == 'created':
|
| 524 |
-
remote=self.remote('liveBroadcasts',ev['broadcast_id'])
|
| 525 |
-
if not remote:
|
| 526 |
-
self.event_update(key,closing=True);self.finish_event(key);return
|
| 527 |
-
life=remote.get('status',{}).get('lifeCycleStatus');self.event_update(key,youtube_status=life)
|
| 528 |
-
ev=self.journal.read()['events'].get(key)
|
| 529 |
-
if ev.get('note') and ('حماية الأحداث' in ev['note'] or 'خطأ داخلي' in ev['note']):
|
| 530 |
-
self._clear_stale_error_note(key)
|
| 531 |
-
ev=self.journal.read()['events'].get(key)
|
| 532 |
-
else:
|
| 533 |
-
remote=None
|
| 534 |
-
|
| 535 |
-
if life in ('complete','revoked'):
|
| 536 |
-
self.event_update(key,closing=True);self.finish_event(key);return
|
| 537 |
-
if life=='live':
|
| 538 |
-
if not ev['was_live']:
|
| 539 |
-
self.event_update(key,was_live=True,phase='live')
|
| 540 |
-
self.log(user,'يوتيوب أكد أن الحدث مباشر: '+ev['broadcast_id'])
|
| 541 |
-
# Now safe to ensure sender is running; viewer already watching
|
| 542 |
-
self.start_sender(key)
|
| 543 |
-
self.stream_health(key,user,ev)
|
| 544 |
-
return
|
| 545 |
-
|
| 546 |
-
# life is expected to be 'ready' or 'testing' for a newly bound event
|
| 547 |
-
if life not in ('ready','testing'):
|
| 548 |
-
# 'created' etc. -> ingest not yet receivable, don't push (prevents 187)
|
| 549 |
-
if life == 'created':
|
| 550 |
-
self.event_note(key,'تم ربط الحدث؛ انتظار جاهزية الاستقبال قبل الإرسال')
|
| 551 |
-
else:
|
| 552 |
-
self.event_note(key,'انتظار تأكيد استقبال يوتيوب قبل بدء الحدث')
|
| 553 |
-
self.stop_sender(key)
|
| 554 |
-
return
|
| 555 |
-
|
| 556 |
-
# life is ready/testing -> we need to push so streamStatus becomes active, then transition
|
| 557 |
-
self.start_sender(key)
|
| 558 |
-
|
| 559 |
-
# Transition to live if YouTube already sees an active stream
|
| 560 |
-
# Only when we did a full poll; otherwise defer transition to next poll
|
| 561 |
-
if needs_poll:
|
| 562 |
-
stream_id=(self.journal.read()['streams'].get(user) or {}).get('id')
|
| 563 |
-
stream=self.remote('liveStreams',stream_id) if stream_id else None
|
| 564 |
-
if stream and stream.get('status',{}).get('streamStatus')=='active' and life in ('ready','testing'):
|
| 565 |
-
self.api.call('liveBroadcasts/transition','POST',{'part':'id,status','id':ev['broadcast_id'],'broadcastStatus':'live'})
|
| 566 |
-
self.event_update(key,phase='starting');self.event_note(key,'يوتيوب يستقبل؛ طُلب بدء الحدث للعامة حسب الخصوصية')
|
| 567 |
-
elif time.time()-ev['created_at']>int(os.getenv('YOUTUBE_START_TIMEOUT','900')) and not ev['was_live']:
|
| 568 |
-
self.event_update(key,closing=True);self.finish_event(key)
|
| 569 |
-
else:
|
| 570 |
-
# keep waiting note, but ensure error note is cleared
|
| 571 |
-
if ev.get('note') and ('حماية الأحداث' in ev['note'] or 'خطأ داخلي' in ev['note']):
|
| 572 |
-
self.event_update(key, note='انتظار تأكيد استقبال يوتيوب قبل بدء الحدث')
|
| 573 |
-
elif not ev.get('note'):
|
| 574 |
-
self.event_note(key,'انتظار تأكيد استقبال يوتيوب قبل بدء الحدث')
|
| 575 |
-
def stream_health(self,key,user,ev):
|
| 576 |
-
"""Read YouTube's own verdict on the inbound stream. The Live Streaming API reports
|
| 577 |
-
exactly why viewers buffer (videoIngestionStarved), why the picture can be rejected
|
| 578 |
-
(videoResolutionUnsupported / videoCodec) and why playback stalls (gopSizeLong /
|
| 579 |
-
openGop). Surfacing it replaces guesswork with ground truth. Polled on a slower
|
| 580 |
-
cadence than the life-cycle check so it stays inside the quota budget."""
|
| 581 |
-
interval=max(60,int(os.getenv('YOUTUBE_HEALTH_INTERVAL','180')))
|
| 582 |
-
if time.time()-float(ev.get('last_health') or 0)<interval:return
|
| 583 |
-
stream_id=(self.journal.read()['streams'].get(user) or {}).get('id')
|
| 584 |
-
if not stream_id:return
|
| 585 |
-
self.event_update(key,last_health=time.time())
|
| 586 |
-
stream=self.remote('liveStreams',stream_id)
|
| 587 |
-
if not stream:return
|
| 588 |
-
health=stream.get('status',{}).get('healthStatus') or {}
|
| 589 |
-
issues=[]
|
| 590 |
-
for i in (health.get('configurationIssues') or []):
|
| 591 |
-
sev=i.get('severity','')
|
| 592 |
-
if sev in ('warning','error'):
|
| 593 |
-
issues.append('%s [%s] %s'%(i.get('type',''),sev,i.get('reason','')))
|
| 594 |
-
text=' | '.join(issues)
|
| 595 |
-
prev=ev.get('last_issues')
|
| 596 |
-
self.event_update(key,youtube_health=health.get('status',''),youtube_issues=text,last_issues=text)
|
| 597 |
-
if text and text!=prev:
|
| 598 |
-
self.log(user,'يوتيوب يشخّص البث: '+text)
|
| 599 |
-
def run_manager(self):
|
| 600 |
-
checked=False
|
| 601 |
-
try:
|
| 602 |
-
while not self.manager_stop.is_set():
|
| 603 |
-
delay=self.scan
|
| 604 |
-
try:
|
| 605 |
-
with self.operations:
|
| 606 |
-
if not checked:self.check_channel();checked=True
|
| 607 |
-
self.tick()
|
| 608 |
-
d=self.journal.read()
|
| 609 |
-
if not d['enabled'] and not d['events']:self.set_status('الأتمتة معطلة؛ لا إرسال ولا إنشاء أحداث')
|
| 610 |
-
except Exception as e:
|
| 611 |
-
text=self.safe_error(e)
|
| 612 |
-
if not isinstance(e,(APIError,RuntimeError,ValueError)):text+=' ('+type(e).__name__+')'
|
| 613 |
-
self.set_status(text)
|
| 614 |
-
if text!=self.last_logged:self.log('youtube',text);self.last_logged=text
|
| 615 |
-
if isinstance(e,APIError) and (e.status in (401,403,429) or e.reason=='invalid_grant'):delay=300
|
| 616 |
-
else:delay=60
|
| 617 |
-
self.manager_stop.wait(delay)
|
| 618 |
-
finally:
|
| 619 |
-
for key in list(self.senders):self.stop_sender(key)
|
| 620 |
-
self.set_status('مدير الأحداث متوقف؛ حالة الجلسات محفوظة')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|