Reoo88 commited on
Commit
f0a41fa
·
verified ·
1 Parent(s): bc98ae5

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.

Files changed (14) hide show
  1. Dockerfile +31 -47
  2. README.md +70 -14
  3. VARIABLES.md +43 -18
  4. app.py +182 -70
  5. deploy_hode_arch.py +5 -2
  6. flv_bridge.py +0 -82
  7. index.html +8 -26
  8. relay.py +0 -191
  9. test_concurrency.py +0 -329
  10. test_geometry.py +249 -0
  11. variables.env +10 -14
  12. verify.sh +0 -139
  13. youtube_core.py +98 -0
  14. 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
- RUN ffmpeg -hide_banner -h protocol=rtmps 2>&1 | grep -q rtmp_enhanced_codecs
 
 
4
  WORKDIR /app
5
  COPY requirements.txt .
6
  RUN pip install --no-cache-dir -r requirements.txt
7
- COPY app.py relay.py youtube_api.py youtube_manager.py youtube_archive.py flv_bridge.py tiktok_auth.py index.html ./
8
 
9
- # --- YouTube ingestion -------------------------------------------------------
10
- # copy = forward the source untouched (original quality, no extra CPU). DEFAULT.
11
- # The YouTube stream is created with cdn.resolution='variable' and
12
- # cdn.frameRate='variable', which per the Live Streaming API means
13
- # "YouTube should automatically detect the resolution of your streamed
14
- # video". So a mid-stream resolution change does NOT require a new
15
- # session, and YOUTUBE_FORMAT_GUARD=off keeps the RTMPS session alive
16
- # across it. Restarting the session was itself a visible gap and, when
17
- # the phone flipped resolution repeatedly, a reconnect storm.
18
- # fixed1080 = constant 1080x1920 canvas, re-encodes to H.264. Zero gaps but costs
19
- # CPU: measured 2.26x real time on 2 vCPU (~44% for one stream).
20
- # fixed720 = constant 720x1280 canvas, 3.72x real time (~27%). Lighter.
21
- # Both canvas modes stay available from Settings if YouTube's own healthStatus ever
22
- # reports videoResolutionUnsupported; they are opt-in because of the CPU cost.
23
- #
24
- # YOUTUBE_FORMAT_GUARD=on -> tear the session down on a resolution change (old
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
- # YOUTUBE_FIFO_TIMESHIFT: the push goes through FFmpeg's fifo pseudo-muxer, which
31
- # reconnects on its own when YouTube drops the connection instead of killing the
32
- # whole process (that is why the recording kept running while the broadcast died).
33
- # timeshift additionally delays the YouTube feed behind the live TikTok source and
34
- # keeps that many seconds buffered, so a source hiccup is covered instead of
35
- # starving YouTube's ingest, and packets survive a reconnect instead of being
36
- # dropped. Cost is latency only, no CPU. https://ffmpeg.org/ffmpeg-formats.html#fifo
37
- # NOTE: fifo's queue_size defaults to 60 packets, under one second of stream, so
38
- # timeshift requires it to be raised or the queue overflows and the stream is
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 رفعات يوميًا فقط، وهي مشتركة مع مدير الأحداث عبر YOUTUBE_DAILY_BUDGET.
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 YOUTUBE_VIDEO_MODE=original PASSWORD=admin
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
- # مرصد البثوث — تسجيل أصلي + أرشفة تلقائية إلى يوتيوب (V10)
10
 
11
  يراقب حسابات تيك توك على مدار الساعة، ويسجّل كل بث **بجودته الأصلية بلا إعادة ترميز
12
  (copy)**، ثم **يرفع التسجيل إلى يوتيوب تلقائيًا ويحذفه من التخزين بعد تأكيد الرفع** —
@@ -20,18 +20,66 @@ colorTo: gray
20
  ```
21
 
22
  لا إعادة ترميز في أي خطوة: الصورة التي تصل يوتيوب هي بايتات تيك توك نفسها.
 
 
23
 
24
- ## ما الجديد في V10
25
 
26
- | المكوّن | الوظيفة |
 
 
 
 
27
  |---|---|
28
- | `youtube_archive.py` | الطابور الدائم + الرفع القابل للاستئناف + التحقق + الحذف الآمن |
29
- | لوحة «الأرشفة التلقائية» | تفعيل/تعطيل، قوالب العنوان والوصف، الخصوصية، الحالة الحيّة لكل ملف |
30
- | `/api/archive` | حالة الطابور · `enable` · `settings` · `scan` · `retry/{name}` |
31
- | حماية الطابور | التنظيف الذكي ممنوع من حذف ملف لم يُرفع بعد (`ARCHIVE_PROTECT_QUEUE`) |
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
- الاختبارات: `python test_archive.py` (19 اختبارًا لوحدة الأرشفة مقابل خادم يحاكي
93
- بروتوكول Google: الاستئناف، نفاد الكوتا، قفل الخصوصية، الرفض، الحماية من التنظيف).
 
 
 
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
- | `YOUTUBE_DAILY_BUDGET` | `9000` | سقف محلي لوحدات الكوتا يوميًا، **مشترك بين البث المباشر والأرشفة** حتى لا يسرق أحدهما حصة الآخر. (الافتراضي داخل الكود `8000`؛ رفعناه في `Dockerfile`.) |
83
- | `YOUTUBE_VIDEO_MODE` | `original` | `original` = أعلى مصدر متاح ولو كان HEVC · `h264` = H.264 فقط. |
84
- | `YOUTUBE_SEND_MODE` | `copy` | `copy` بلا إعادة ترميز · `fixed1080` · `fixed720`. |
85
- | `YOUTUBE_MAX_EVENTS` | `3` | أقصى أحداث بث متزامنة. |
86
- | `YOUTUBE_SCAN_SECONDS` | `30` | دورة فحص المذيعين لفتح الأحداث. |
87
- | `YOUTUBE_END_GRACE` | `90` | مهلة قبل إنهاء الحدث بعد اختفاء البث. |
88
- | `YOUTUBE_FORMAT_GUARD` | `off` | `on` = إنهاء الجلسة عند تغيّر الدقة (فجوة 1-3 ثوانٍ). |
89
- | `YOUTUBE_FLAP_LIMIT` | `4` | عدد تقلّبات الدقة خلال 10 دقائق قبل السقوط للوضع البديل. |
90
- | `YOUTUBE_FLAP_FALLBACK_MODE` | `fixed720` | الوضع البديل عند التقلّب. |
91
- | `YOUTUBE_FIFO_TIMESHIFT` | `10` | ثوانٍ تأخير/تخزين مؤقت تحمي من انقطاع الاستقبال. |
92
- | `YOUTUBE_FIFO_QUEUE` | `0` | حجم طابور fifo (صفر = يُحسب تلقائيًا `ts*250+500`). |
93
- | `YOUTUBE_HEALTH_INTERVAL` | `180` | ثوانٍ بين قراءات `healthStatus` من يوتيوب. |
94
- | `YOUTUBE_X264_PRESET` | `veryfast` | إعداد الترميز عند استعمال وضع fixed. |
95
- | `YOUTUBE_CRF` | `22` | جودة الترميز عند fixed. |
96
- | `YOUTUBE_RECOVERY_WAIT` | `1` | ثوانٍ قبل إعادة محاولة الإرسال. |
97
- | `YOUTUBE_START_TIMEOUT` | `900` | مهلة انتظار جاهزية الاستقبال. |
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- from youtube_manager import ManagedYouTube
267
- def enabled_accounts():
268
- with lock:return [u for u,t in tasks.items() if t.get('enabled')]
269
- youtube=ManagedYouTube(FFMPEG,observe_session,log,enabled_accounts,ROOT/'youtube-api-state.json',headers_for=tiktok_headers)
 
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
- if self.ts_offset or self.check_cfg:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- def finalize(raw,stem,user,meta=None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- measured=inspect_media(mp4)
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
- # Already deleted mkv above. Also ensure we keep only KEEP_LAST_FILES
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
- try:finalize(raw,stem,user,meta)
 
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
- class YouTubeStart(BaseModel):
560
- channel_id:str
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':'V10-archive','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()}
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
- youtube.restore()
 
 
 
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
- 'YOUTUBE_VIDEO_MODE': 'original',
 
 
 
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='V10: أرشفة تلقائية للتسجيلات إلى يوتيوب')
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>مدير أحداث يوتيوب — بث متزامن</h2>
32
- <p>يفحص الحسابات المفعّلة، وكل مذيع يفتح بثًا مباشرًا يصير له حدث يوتيوب مستقل باسمه وعنوانه، وتبقى الأحداث كلها تعمل بنفس الوقت حتى تنتهي جلساتها. جلسة جديدة = حدث جديد. لا يرسل شاشة انتظار.</p>
33
- <div class="row"><button id="ytcheck" class="secondary">١. فحص OAuth والقناة</button><button id="ytstart">٢. تفعيل الإدارة التلقائية</button><button id="ytstop" class="danger">تعطيل وإنهاء الأحداث الجارية</button></div>
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
65
- let settingsLoaded=false,channelId='',lastY=null,polling=false;
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
- const evs=y.events||[];
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=`${y.automatic?'الإدارة التلقائية مفعّلة':'الإدارة التلقائية معطّلة'} · وضع المصدر: ${y.video_mode||'original'} · وضع الإرسال: ${y.send_mode||'copy'} · أحداث جارية: ${evs.length}\n${y.status}`;
76
- $('events').innerHTML=evs.length?evs.map(e=>`<article class="card"><h3>${esc(e.title||'حدث بث')}</h3><p>@${esc(e.username)}${e.nickname?' · '+esc(e.nickname):''}</p><small>المرحلة: ${esc(e.phase||'—')} · حالة يوتيوب: ${esc(e.youtube_status||'—')}${e.closing?' · جارٍ الإنهاء':''}</small><p>${esc(e.status||e.note||'—')}</p><small>${e.source?esc('مصدر: '+e.source+' · '):''}${Math.floor(e.output_seconds||0)} ثانية إرسال</small>${e.broadcast_id?`<p><a target="_blank" rel="noopener noreferrer" href="https://www.youtube.com/watch?v=${encodeURIComponent(e.broadcast_id)}">فتح الحدث على يوتيوب</a></p>`:''}</article>`).join(''):'<p>لا توجد أحداث جارية. عند بدء أي مذيع مباشرة يُفتح حدث مستقل باسمه وعنوانه من حسابه.</p>';
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لم يُنشأ أو يُنشر أي حدث بهذا الفحص.`)}catch(e){error(e.message)}finally{$('ytcheck').disabled=false}};
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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('مدير الأحداث متوقف؛ حالة الجلسات محفوظة')