QA commited on
Commit ·
17fefbc
1
Parent(s): 92f0b6b
copy the full relay project into this test Space + the three lab-verified fixes (format guard on, timestamp-reset detector, optional audio map)
Browse files- Dockerfile +61 -5
- README.md +13 -1
- app.py +541 -279
- flv_bridge.py +107 -0
- index.html +108 -0
- relay.py +194 -0
- requirements.txt +3 -0
- tiktok_auth.py +476 -0
- verify.sh +139 -0
- verify_tokens.py +152 -0
- youtube_api.py +143 -0
- youtube_manager.py +698 -0
Dockerfile
CHANGED
|
@@ -1,9 +1,65 @@
|
|
| 1 |
-
FROM python:3.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
|
|
|
| 8 |
EXPOSE 7860
|
| 9 |
-
CMD ["
|
|
|
|
| 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 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; whether the session survives a mid-stream change is decided
|
| 16 |
+
# by YOUTUBE_FORMAT_GUARD below.
|
| 17 |
+
# fixed1080 = constant 1080x1920 canvas, re-encodes to H.264. Zero gaps but costs
|
| 18 |
+
# CPU: measured 2.26x real time on 2 vCPU (~44% for one stream).
|
| 19 |
+
# fixed720 = constant 720x1280 canvas, 3.72x real time (~27%). Lighter.
|
| 20 |
+
# Both canvas modes stay available from Settings if YouTube's own healthStatus ever
|
| 21 |
+
# reports videoResolutionUnsupported; they are opt-in because of the CPU cost.
|
| 22 |
+
#
|
| 23 |
+
# YOUTUBE_FORMAT_GUARD=on -> DEFAULT NOW. tear the session down on a resolution
|
| 24 |
+
# change so YouTube re-declares the new size explicitly (1-3 s gap) instead of
|
| 25 |
+
# stretching/re-locking the old size over the new frames, which is what showed up
|
| 26 |
+
# to viewers as zoom, stutter, a long repeated clip, freeze or a black flash.
|
| 27 |
+
# YOUTUBE_FLAP_LIMIT: if resolution flips this many times in 10 minutes, copy mode
|
| 28 |
+
# falls back to YOUTUBE_FLAP_FALLBACK_MODE for the rest of the event. Dormant
|
| 29 |
+
# while the guard is off, kept as a safety net.
|
| 30 |
+
#
|
| 31 |
+
# YOUTUBE_FIFO_TIMESHIFT: the push goes through FFmpeg's fifo pseudo-muxer, which
|
| 32 |
+
# reconnects on its own when YouTube drops the connection instead of killing the
|
| 33 |
+
# whole process (that is why the recording kept running while the broadcast died).
|
| 34 |
+
# timeshift additionally delays the YouTube feed behind the live TikTok source and
|
| 35 |
+
# keeps that many seconds buffered, so a source hiccup is covered instead of
|
| 36 |
+
# starving YouTube's ingest, and packets survive a reconnect instead of being
|
| 37 |
+
# dropped. Cost is latency only, no CPU. https://ffmpeg.org/ffmpeg-formats.html#fifo
|
| 38 |
+
# NOTE: fifo's queue_size defaults to 60 packets, under one second of stream, so
|
| 39 |
+
# timeshift requires it to be raised or the queue overflows and the stream is
|
| 40 |
+
# destroyed instead of delayed. relay.py sizes it automatically (ts*250+500).
|
| 41 |
+
# YOUTUBE_HEALTH_INTERVAL: seconds between reads of YouTube's own
|
| 42 |
+
# healthStatus/configurationIssues while live. This is what reports videoIngestion
|
| 43 |
+
# Starved (viewers buffer), gopSizeLong, openGop, videoResolutionUnsupported and
|
| 44 |
+
# videoCodec, i.e. the ground truth instead of a guess.
|
| 45 |
+
ENV YOUTUBE_SEND_MODE=copy \
|
| 46 |
+
YOUTUBE_FORMAT_GUARD=1 \
|
| 47 |
+
YOUTUBE_FLAP_LIMIT=4 \
|
| 48 |
+
YOUTUBE_FLAP_FALLBACK_MODE=fixed720 \
|
| 49 |
+
YOUTUBE_X264_PRESET=veryfast \
|
| 50 |
+
YOUTUBE_CRF=22 \
|
| 51 |
+
YOUTUBE_RECOVERY_WAIT=1 \
|
| 52 |
+
YOUTUBE_FIFO_TIMESHIFT=10 \
|
| 53 |
+
YOUTUBE_HEALTH_INTERVAL=180
|
| 54 |
|
| 55 |
+
# --- TikTok session token (age-restricted 18+ rooms) -------------------------
|
| 56 |
+
# Set TIKTOK_SESSIONID in Settings > Variables and secrets, or paste it into the
|
| 57 |
+
# UI panel (no Factory reboot needed). Sessions are short-lived (~6 h per
|
| 58 |
+
# sid_guard), so the UI panel is the practical way to refresh.
|
| 59 |
+
# Per account: TIKTOK_SESSIONID_<USER> (upper-case, non A-Z0-9 -> _)
|
| 60 |
+
# Compact map: TIKTOK_TOKENS=user1=token1;user2=token2
|
| 61 |
+
ENV TIKTOK_TT_TARGET_IDC=useast1a
|
| 62 |
|
| 63 |
+
ENV PORT=7860 PYTHONUNBUFFERED=1 YOUTUBE_VIDEO_MODE=original
|
| 64 |
EXPOSE 7860
|
| 65 |
+
CMD ["sh", "-c", "exec uvicorn app:app --host 0.0.0.0 --port ${PORT:-7860} --timeout-graceful-shutdown 65"]
|
README.md
CHANGED
|
@@ -5,6 +5,18 @@ colorFrom: purple
|
|
| 5 |
colorTo: red
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
colorTo: red
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
+
app_port: 7860
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# Reoo88/On — نسخة اختبار (Staging) لمشروع إعادة بث تيك توك ← يوتيوب
|
| 12 |
+
|
| 13 |
+
نسخة مطابقة لمشروع الترحيل، مخصّصة لاختبار التعديلات قبل نقلها إلى السبيس الأساسي.
|
| 14 |
+
منسوخة من شجرة الإنتاج ومبنية عليها التعديلات الثلاثة المُختبرة معملياً:
|
| 15 |
+
|
| 16 |
+
1. **حماية تغيّر الترويسة (Format Guard) مفعّلة افتراضياً** — إعادة جلسة نظيفة عند تغيّر دقة المصدر بدل بقاء يوتيوب مقفولاً على المقاس القديم.
|
| 17 |
+
2. **كاشف إعادة تعيين الطوابع الزمنية** — أي رجوع للخلف أكثر من ٥٠٠ ملّي ثانية (تبديل CDN/إعادة تشغيل البث) يفتح جلسة إرسال جديدة: يمنع تكرار الصورة وضياع نصف حزم الصوت.
|
| 18 |
+
3. **حماية المسار الصوتي** — `-map 0:a:0?` اختياري حتى لا يُقتل الإرسال لمذيع بلا صوت.
|
| 19 |
+
|
| 20 |
+
محفوظ كما هو: `-c:v copy` (بلا إعادة ترميز فيديو) · إخراج `-f fifo -timeshift 10` · تمرير HEVC عبر Enhanced RTMP (بلا أي فلتر H.264).
|
| 21 |
+
|
| 22 |
+
> **تنبيه:** الأتمتة مطفأة افتراضياً في هذه النسخة. لا تُشغّلها إلا بعد إيقاف الأتمتة في السبيس الأساسي، وإلا نُشر البث مرتين على نفس قناة يوتيوب.
|
app.py
CHANGED
|
@@ -1,285 +1,547 @@
|
|
| 1 |
-
import os
|
| 2 |
-
import
|
| 3 |
-
import
|
| 4 |
-
import
|
| 5 |
-
import
|
| 6 |
-
from
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
try:
|
| 36 |
-
|
| 37 |
-
for
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
try:
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
try:
|
| 143 |
-
|
| 144 |
-
except:
|
| 145 |
pass
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
| 168 |
while True:
|
|
|
|
| 169 |
try:
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
if ffmpeg_proc and ffmpeg_proc.poll() is not None:
|
| 175 |
-
exit_code = ffmpeg_proc.poll()
|
| 176 |
-
log(f"⚠️ ffmpeg مات! exit code: {exit_code}")
|
| 177 |
-
if os.path.exists(FFMPEG_LOG):
|
| 178 |
-
with open(FFMPEG_LOG) as f:
|
| 179 |
-
log("📋 آخر سجلات ffmpeg:\n" + "".join(f.readlines()[-10:]))
|
| 180 |
-
ffmpeg_restart_count += 1
|
| 181 |
-
if ffmpeg_restart_count >= 10:
|
| 182 |
-
log("💀 10 محاولات فاشلة – توقف لمدة 5 دقائق")
|
| 183 |
-
kill_process_tree(ffmpeg_proc.pid)
|
| 184 |
-
ffmpeg_proc = None
|
| 185 |
-
is_streaming = False
|
| 186 |
-
was_streaming = False
|
| 187 |
-
current_status["live"] = False
|
| 188 |
-
current_status["pid"] = None
|
| 189 |
-
time.sleep(300)
|
| 190 |
-
ffmpeg_restart_count = 0
|
| 191 |
-
continue
|
| 192 |
-
time.sleep(10)
|
| 193 |
-
|
| 194 |
-
log("🟢 بدء بث جديد...")
|
| 195 |
-
if ffmpeg_proc:
|
| 196 |
-
kill_process_tree(ffmpeg_proc.pid)
|
| 197 |
-
ffmpeg_proc = start_stream(STREAM_KEY, stream_url)
|
| 198 |
-
if ffmpeg_proc:
|
| 199 |
-
is_streaming = True
|
| 200 |
-
was_streaming = True
|
| 201 |
-
consecutive_fails = 0
|
| 202 |
-
ffmpeg_restart_count = 0
|
| 203 |
-
current_status["live"] = True
|
| 204 |
-
current_status["pid"] = ffmpeg_proc.pid
|
| 205 |
-
current_status["start_time"] = time.strftime("%H:%M:%S")
|
| 206 |
-
else:
|
| 207 |
-
consecutive_fails += 1
|
| 208 |
-
if was_streaming and consecutive_fails > 5:
|
| 209 |
-
log("⏹️ توقف البث")
|
| 210 |
-
if ffmpeg_proc:
|
| 211 |
-
kill_process_tree(ffmpeg_proc.pid)
|
| 212 |
-
ffmpeg_proc = None
|
| 213 |
-
is_streaming = False
|
| 214 |
-
was_streaming = False
|
| 215 |
-
consecutive_fails = 0
|
| 216 |
-
current_status["live"] = False
|
| 217 |
-
current_status["pid"] = None
|
| 218 |
-
|
| 219 |
-
# تحديث الإحصائيات
|
| 220 |
-
try:
|
| 221 |
-
load1, load5, load15 = os.getloadavg()
|
| 222 |
-
mem = psutil.virtual_memory()
|
| 223 |
-
current_status["cpu_load"] = f"{load1:.2f} / {load5:.2f} / {load15:.2f}"
|
| 224 |
-
current_status["ram"] = f"{mem.percent:.1f}%"
|
| 225 |
-
except:
|
| 226 |
-
pass
|
| 227 |
-
|
| 228 |
-
if ffmpeg_proc and ffmpeg_proc.poll() is None:
|
| 229 |
-
try:
|
| 230 |
-
p = psutil.Process(ffmpeg_proc.pid)
|
| 231 |
-
current_status["ffmpeg_cpu"] = f"{p.cpu_percent(interval=1):.1f}%"
|
| 232 |
-
except:
|
| 233 |
-
current_status["ffmpeg_cpu"] = "?"
|
| 234 |
-
else:
|
| 235 |
-
current_status["ffmpeg_cpu"] = "—"
|
| 236 |
-
|
| 237 |
-
current_status["last_check"] = time.strftime("%H:%M:%S")
|
| 238 |
-
time.sleep(30)
|
| 239 |
-
|
| 240 |
-
except Exception as e:
|
| 241 |
-
log(f"💥 خطأ: {e}")
|
| 242 |
-
time.sleep(30)
|
| 243 |
-
|
| 244 |
-
app = Flask(__name__)
|
| 245 |
-
|
| 246 |
-
@app.route("/")
|
| 247 |
-
def index():
|
| 248 |
-
s = current_status
|
| 249 |
-
status_emoji = "🔴" if s["live"] else "⚫"
|
| 250 |
-
html = f"""
|
| 251 |
-
<html dir="rtl">
|
| 252 |
-
<head><meta charset="UTF-8"><title>مراقب البث</title>
|
| 253 |
-
<meta http-equiv="refresh" content="15">
|
| 254 |
-
<style>
|
| 255 |
-
body {{ font-family: sans-serif; background: #0e1117; color: #eee; padding: 30px; }}
|
| 256 |
-
.card {{ background: #1e222d; padding: 20px; border-radius: 12px; margin: 10px 0; }}
|
| 257 |
-
h1 {{ color: #ff4b4b; }}
|
| 258 |
-
</style></head>
|
| 259 |
-
<body>
|
| 260 |
-
<h1>📡 رادار بث {TIKTOK_USERNAME}</h1>
|
| 261 |
-
<div class="card">
|
| 262 |
-
<strong>الحالة:</strong> {status_emoji} {"المذيع يبث الآن!" if s["live"] else "متوقف"}
|
| 263 |
-
<br>معرف العملية: {s.get("pid") or "—"}
|
| 264 |
-
<br>بدء البث: {s.get("start_time") or "—"}
|
| 265 |
-
</div>
|
| 266 |
-
<div class="card">
|
| 267 |
-
<strong>تحميل المعالج (كامل):</strong> {s["cpu_load"]}
|
| 268 |
-
<br><strong>الذاكرة:</strong> {s["ram"]}
|
| 269 |
-
<br><strong>🎯 استهلاك ffmpeg الفعلي:</strong> {s["ffmpeg_cpu"]}
|
| 270 |
-
<br>آخر فحص: {s["last_check"]}
|
| 271 |
-
</div>
|
| 272 |
-
<div class="card">
|
| 273 |
-
<h3>آخر سجلات النظام</h3>
|
| 274 |
-
<pre style="color:#aaa; background:#000; padding:10px;">{open(MONITOR_LOG).read()[-2000:] if os.path.exists(MONITOR_LOG) else "لا يوجد سجل"}</pre>
|
| 275 |
-
</div>
|
| 276 |
-
</body>
|
| 277 |
-
</html>
|
| 278 |
-
"""
|
| 279 |
-
return html
|
| 280 |
-
|
| 281 |
-
if __name__ == "__main__":
|
| 282 |
-
log("✅ بدء تشغيل المحرك + خادم المراقبة")
|
| 283 |
-
t = threading.Thread(target=monitor_loop, daemon=True)
|
| 284 |
-
t.start()
|
| 285 |
-
app.run(host="0.0.0.0", port=7860, debug=False)
|
|
|
|
| 1 |
+
import os, re, json, time, threading, subprocess, shutil, secrets, logging
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from urllib.parse import urlparse
|
| 4 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 5 |
+
from fastapi import FastAPI, Depends, HTTPException
|
| 6 |
+
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
| 7 |
+
from fastapi.responses import HTMLResponse, FileResponse
|
| 8 |
+
from pydantic import BaseModel
|
| 9 |
+
from curl_cffi import requests
|
| 10 |
+
import tiktok_auth
|
| 11 |
+
|
| 12 |
+
ROOT=Path(os.getenv('DATA_DIR','/data/tiktok-recorder' if Path('/data').is_dir() else './storage')).resolve()
|
| 13 |
+
ROOT.mkdir(parents=True,exist_ok=True)
|
| 14 |
+
FILES=ROOT/'recordings'; FILES.mkdir(exist_ok=True)
|
| 15 |
+
TOKENS=ROOT/'tokens.json'
|
| 16 |
+
tiktok_auth.configure(ROOT/'tiktok-tokens.json')
|
| 17 |
+
PASSWORD=os.getenv('PASSWORD','')
|
| 18 |
+
if not PASSWORD: raise RuntimeError('Set PASSWORD before starting the app')
|
| 19 |
+
FFMPEG=os.getenv('FFMPEG_PATH') or shutil.which('ffmpeg')
|
| 20 |
+
if not FFMPEG: raise RuntimeError('ffmpeg is required')
|
| 21 |
+
MKVMERGE=os.getenv('MKVMERGE_PATH') or shutil.which('mkvmerge')
|
| 22 |
+
FFPROBE=os.getenv('FFPROBE_PATH') or shutil.which('ffprobe')
|
| 23 |
+
if not MKVMERGE or not FFPROBE: raise RuntimeError('mkvmerge and ffprobe are required')
|
| 24 |
+
FINALIZE_LOCK=threading.Lock()
|
| 25 |
+
INTERVAL=max(30,int(os.getenv('CHECK_INTERVAL','60')))
|
| 26 |
+
SEGMENT=max(0,int(os.getenv('SEGMENT_SECONDS','0'))) # 0 = no scheduled split
|
| 27 |
+
# 0 أو بدون قيمة = بلا حد. الحد الثابت (3) كان يمنع إضافة مذيعين ويقصّ الحسابات المحفوظة عند التشغيل.
|
| 28 |
+
_LIMIT=int(os.getenv('MAX_ACCOUNTS','0') or 0)
|
| 29 |
+
MAX_ACCOUNTS=_LIMIT if _LIMIT>0 else None
|
| 30 |
+
MIN_FREE_MB=int(os.getenv('MIN_FREE_MB','1024'))
|
| 31 |
+
# SMART CLEANUP: guarantee free space for continuous broadcast
|
| 32 |
+
KEEP_LAST_FILES=max(1,int(os.getenv('KEEP_LAST_FILES','2'))) # keep newest N files always
|
| 33 |
+
ENSURE_FREE_MB=max(MIN_FREE_MB*2,int(os.getenv('ENSURE_FREE_MB','2048'))) # target free before/after finalize
|
| 34 |
+
AUTO_DELETE_MKV=True # never keep .mkv if .mp4 succeeded (user request)
|
| 35 |
+
lock=threading.RLock(); tasks={}; events=[]; handles={}
|
| 36 |
+
security=HTTPBasic()
|
| 37 |
+
def auth(c:HTTPBasicCredentials=Depends(security)):
|
| 38 |
+
if not (secrets.compare_digest(c.username.encode(),b'admin') and secrets.compare_digest(c.password.encode(),PASSWORD.encode())):
|
| 39 |
+
raise HTTPException(401,'Unauthorized',headers={'WWW-Authenticate':'Basic'})
|
| 40 |
+
app=FastAPI(dependencies=[Depends(auth)],docs_url=None,redoc_url=None,openapi_url=None)
|
| 41 |
+
def log(user,msg):
|
| 42 |
+
with lock:
|
| 43 |
+
events.append({'time':time.strftime('%H:%M:%S'),'user':user,'message':msg})
|
| 44 |
+
del events[:-200]
|
| 45 |
+
def update(user,**kw):
|
| 46 |
+
with lock: tasks[user].update(kw)
|
| 47 |
+
def save():
|
| 48 |
+
with lock:
|
| 49 |
+
tmp=ROOT/'tasks.tmp';tmp.write_text(json.dumps({u:t['enabled'] for u,t in tasks.items()}));tmp.replace(ROOT/'tasks.json')
|
| 50 |
+
|
| 51 |
+
def smart_cleanup(reason="periodic"):
|
| 52 |
+
"""Intelligent deletion: always keep KEEP_LAST_FILES newest, delete oldest until ENSURE_FREE_MB is free.
|
| 53 |
+
Never touches .partial. files (currently recording/finalizing). Deletes .mkv first if MP4 exists."""
|
| 54 |
try:
|
| 55 |
+
# Never delete partial files
|
| 56 |
+
all_completed = [p for p in FILES.iterdir() if p.is_file() and '.partial.' not in p.name and p.suffix in ('.mp4','.ts','.mkv','.flv') and p.name != '.gitkeep']
|
| 57 |
+
if not all_completed:
|
| 58 |
+
return 0
|
| 59 |
+
# Sort by mtime: newest first
|
| 60 |
+
all_completed.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
| 61 |
+
# Always keep newest KEEP_LAST_FILES
|
| 62 |
+
keep = set(all_completed[:KEEP_LAST_FILES])
|
| 63 |
+
candidates = all_completed[KEEP_LAST_FILES:] # oldest first will be at end, but we iterate oldest first for deletion
|
| 64 |
+
candidates.sort(key=lambda p: p.stat().st_mtime) # oldest first
|
| 65 |
+
freed=0
|
| 66 |
+
deleted=[]
|
| 67 |
+
# Phase 1: delete .mkv files that have a corresponding .mp4 (redundant) - user requested no mkv
|
| 68 |
+
if AUTO_DELETE_MKV:
|
| 69 |
+
for p in list(candidates):
|
| 70 |
+
if p.suffix == '.mkv':
|
| 71 |
+
# if same stem has mp4, it's redundant
|
| 72 |
+
stem = p.stem
|
| 73 |
+
# stem may be like user_20260917_... - check if any mp4 with similar prefix exists in keep or candidates
|
| 74 |
+
# simpler: just delete all mkv older than keep
|
| 75 |
+
try:
|
| 76 |
+
sz=p.stat().st_size
|
| 77 |
+
p.unlink()
|
| 78 |
+
freed+=sz
|
| 79 |
+
deleted.append(p.name)
|
| 80 |
+
candidates.remove(p)
|
| 81 |
+
except OSError:
|
| 82 |
+
pass
|
| 83 |
+
# also check keep set for mkv that have mp4 equivalent? Keep as is if user wants to keep 2 newest mkv? but user said no need mkv
|
| 84 |
+
for p in list(keep):
|
| 85 |
+
if p.suffix == '.mkv':
|
| 86 |
+
# if there's an mp4 with same date prefix, delete mkv even from keep? But respect KEEP_LAST_FILES: keep mp4 instead
|
| 87 |
+
# Find if there's a newer mp4 that covers same period - just keep it for now, but log
|
| 88 |
+
pass
|
| 89 |
+
|
| 90 |
+
# Phase 2: ensure free space
|
| 91 |
+
free_mb = shutil.disk_usage(FILES).free / (1024*1024)
|
| 92 |
+
# Also consider if total size of completed files > 80% of disk, proactively clean
|
| 93 |
+
for p in candidates:
|
| 94 |
+
if free_mb >= ENSURE_FREE_MB:
|
| 95 |
+
break
|
| 96 |
+
try:
|
| 97 |
+
sz=p.stat().st_size
|
| 98 |
+
p.unlink()
|
| 99 |
+
freed+=sz
|
| 100 |
+
free_mb += sz/(1024*1024)
|
| 101 |
+
deleted.append(p.name)
|
| 102 |
+
except OSError:
|
| 103 |
+
continue
|
| 104 |
+
if deleted:
|
| 105 |
+
log('system', f'تنظيف ذكي ({reason}): حذف {len(deleted)} ملف قديم لتوفير {round(freed/1048576,1)}MB — ضمنا {int(free_mb)}MB فراغ للبث المستمر (احتفظنا بآخر {KEEP_LAST_FILES})')
|
| 106 |
+
return len(deleted)
|
| 107 |
+
except Exception as e:
|
| 108 |
+
log('system', f'تعذر التنظيف الذكي: {type(e).__name__}')
|
| 109 |
+
return 0
|
| 110 |
+
# TikTok session tokens (sessionid) — required for age-restricted (18+) lives.
|
| 111 |
+
# Stored per account; a global fallback can be set via the TIKTOK_SESSIONID secret.
|
| 112 |
+
_tokens={}
|
| 113 |
+
def load_tokens():
|
| 114 |
+
global _tokens
|
| 115 |
+
try:_tokens=json.loads(TOKENS.read_text())
|
| 116 |
+
except (FileNotFoundError,ValueError,OSError):_tokens={}
|
| 117 |
+
# One-way migration so tokens saved by earlier versions gain expiry tracking.
|
| 118 |
+
for u,t in list(_tokens.items()):
|
| 119 |
+
try:
|
| 120 |
+
if t and t.strip():tiktok_auth.save(u,t)
|
| 121 |
+
except (ValueError,OSError):pass
|
| 122 |
+
def save_tokens():
|
| 123 |
+
with lock:
|
| 124 |
+
tmp=ROOT/'tokens.tmp';tmp.write_text(json.dumps(_tokens));tmp.replace(TOKENS)
|
| 125 |
+
def set_token(user,token):
|
| 126 |
+
token=(token or '').strip()
|
| 127 |
+
with lock:
|
| 128 |
+
if token:
|
| 129 |
+
_tokens[user]=token
|
| 130 |
+
try:tiktok_auth.save(user,token)
|
| 131 |
+
except ValueError:pass # keep the raw value; the UI reports format problems separately
|
| 132 |
+
else:
|
| 133 |
+
_tokens.pop(user,None)
|
| 134 |
+
tiktok_auth.delete(user)
|
| 135 |
+
save_tokens()
|
| 136 |
+
def get_token(user):
|
| 137 |
+
with lock:return _tokens.get(user) or os.getenv('TIKTOK_SESSIONID','').strip()
|
| 138 |
+
def tiktok_headers(user):
|
| 139 |
+
# Accepts a bare sessionid value or a full cookie string. Never logged or echoed back.
|
| 140 |
+
# tiktok_auth expands it to the cookie set TikTok's own web client sends for
|
| 141 |
+
# age-restricted rooms: sessionid + sessionid_ss + sid_tt + tt-target-idc.
|
| 142 |
+
tok=get_token(user) if user else ''
|
| 143 |
+
if not tok:return {}
|
| 144 |
+
sessionid,pairs=tiktok_auth.parse_token(tok)
|
| 145 |
+
if not sessionid:return {}
|
| 146 |
+
return {'Cookie':'; '.join('%s=%s'%(k,v) for k,v in pairs.items())}
|
| 147 |
+
def clean_user(raw):
|
| 148 |
+
raw=raw.strip()
|
| 149 |
+
if raw.startswith('https://'):
|
| 150 |
+
p=urlparse(raw)
|
| 151 |
+
if p.hostname not in ('www.tiktok.com','tiktok.com','vm.tiktok.com','vt.tiktok.com'):raise ValueError('الرابط يجب أن يكون من TikTok')
|
| 152 |
+
# Only direct profile/live URLs: avoids following arbitrary redirects server-side.
|
| 153 |
+
m=re.fullmatch(r'/@([\w.]{1,32})(?:/live)?/?',p.path)
|
| 154 |
+
if not m:raise ValueError('استخدم اسم الحساب أو رابطه الكامل، وليس رابط المشاركة المختصر')
|
| 155 |
+
raw=m.group(1)
|
| 156 |
+
raw=raw.lstrip('@')
|
| 157 |
+
if not re.fullmatch(r'[A-Za-z0-9_.]{1,32}',raw):raise ValueError('اسم حساب غير صالح')
|
| 158 |
+
return raw.lower()
|
| 159 |
+
def get_json(url,params,user=None):
|
| 160 |
+
r=requests.get(url,params=params,impersonate='chrome',headers={'Referer':'https://www.tiktok.com/',**tiktok_headers(user)},timeout=20)
|
| 161 |
+
r.raise_for_status();return r.json()
|
| 162 |
+
_observation_cache={}
|
| 163 |
+
_observation_locks={}
|
| 164 |
+
_observation_guard=threading.Lock()
|
| 165 |
+
def observe_session(user):
|
| 166 |
+
with _observation_guard:guard=_observation_locks.setdefault(user,threading.Lock())
|
| 167 |
+
with guard:
|
| 168 |
+
cached=_observation_cache.get(user)
|
| 169 |
+
if cached and time.monotonic()-cached[0]<15:return cached[1]
|
| 170 |
+
result=_observe_session(user)
|
| 171 |
+
_observation_cache[user]=(time.monotonic(),result)
|
| 172 |
+
return result
|
| 173 |
+
|
| 174 |
+
def _observe_session(user):
|
| 175 |
+
j=get_json('https://www.tiktok.com/api-live/user/room',{'aid':1988,'uniqueId':user,'sourceType':54,'staleTime':0},user)
|
| 176 |
+
d=j.get('data',{});live=d.get('liveRoom',{});status=live.get('status');person=d.get('user',{})
|
| 177 |
+
room=str(person.get('roomId') or person.get('room_id') or '')
|
| 178 |
+
nickname=str(person.get('nickname') or '').strip()
|
| 179 |
+
live_title=str(live.get('title') or '').strip()
|
| 180 |
+
if status in (4,'4'):return {'status':'offline','room_id':room,'sources':[],'nickname':nickname,'live_title':live_title}
|
| 181 |
+
blocks=[live[k] for k in ('streamData','hevcStreamData') if isinstance(live.get(k),dict)]
|
| 182 |
+
sources=rank_sources({'blocks':blocks}) if status in (2,'2') else []
|
| 183 |
+
# Live but every stream block is empty: the classic age-restricted (18+) shape.
|
| 184 |
+
if status in (2,'2') and not sources and tiktok_auth.classify_restriction(j):
|
| 185 |
+
raise RuntimeError(MSG)
|
| 186 |
+
if not room or room=='0':
|
| 187 |
+
r=requests.get(f'https://www.tiktok.com/@{user}/live',impersonate='chrome',headers={'Referer':'https://www.tiktok.com/',**tiktok_headers(user)},timeout=20)
|
| 188 |
+
r.raise_for_status();m=re.search(r'"roomId"\s*:\s*"(\d+)"',r.text)
|
| 189 |
+
room=m.group(1) if m else ''
|
| 190 |
+
if not room.isdigit() or room=='0':raise RuntimeError('تعذر تحديد معرّف جلسة البث؛ لن ننشئ حدث يوتيوب بالتخمين')
|
| 191 |
+
if not sources:
|
| 192 |
+
j=get_json('https://webcast.tiktok.com/webcast/room/info/',{'aid':1988,'room_id':room},user)
|
| 193 |
+
info=j.get('data',{});status=info.get('status');owner=info.get('owner') or {}
|
| 194 |
+
nickname=str(owner.get('nickname') or '').strip() or nickname
|
| 195 |
+
live_title=str(info.get('title') or '').strip() or live_title
|
| 196 |
+
if status in (4,'4'):return {'status':'offline','room_id':room,'sources':[],'nickname':nickname,'live_title':live_title}
|
| 197 |
+
if status not in (2,'2'):
|
| 198 |
+
if tiktok_auth.classify_restriction(j):raise RuntimeError(MSG)
|
| 199 |
+
raise RuntimeError('حالة تيك توك غير مؤكدة؛ لا نعتبرها انتهاء للبث')
|
| 200 |
+
sources=rank_sources(info.get('stream_url') or {})
|
| 201 |
+
if not sources and tiktok_auth.classify_restriction(j):raise RuntimeError(MSG)
|
| 202 |
+
return {'status':'live','room_id':room,'sources':sources,'nickname':nickname,'live_title':live_title}
|
| 203 |
+
|
| 204 |
+
def extract(user):
|
| 205 |
+
observed=observe_session(user)
|
| 206 |
+
if observed['status']=='offline':return []
|
| 207 |
+
if not observed['sources']:raise RuntimeError('البث مباشر لكن روابط الفيديو غير متاحة — '+MSG)
|
| 208 |
+
return observed['sources']
|
| 209 |
+
|
| 210 |
+
def rank_sources(source):
|
| 211 |
+
candidates=[]
|
| 212 |
+
priorities={'origin':100,'original':100,'origion':100,'uhd':90,'uhd_60':95,'hd_60':75,'full_hd1':90,'full_hd':90,'hd1':70,'hd':70,'sd2':40,'sd':40,'sd1':30,'ld':20}
|
| 213 |
+
def add(url,label,params=None,assumed=False):
|
| 214 |
+
if not isinstance(url,str) or not url.startswith('https://') or '.m3u8' in url:return
|
| 215 |
+
if str(label).lower() in ('ao','audio','audio_only'):return
|
| 216 |
+
params=params or {};res=str(params.get('resolution',''))
|
| 217 |
+
nums=re.findall(r'\d+',res)
|
| 218 |
+
pixels=int(nums[0])*int(nums[1]) if len(nums)>1 else (int(nums[0])**2 if nums else 0)
|
| 219 |
+
try:rate=float(params.get('vbitrate') or 0)
|
| 220 |
+
except (ValueError,TypeError):rate=0
|
| 221 |
+
quality=priorities.get(str(label).lower(),50)
|
| 222 |
+
# Explicit original labels first; use declared dimensions/rate when supplied.
|
| 223 |
+
score=(quality==100, pixels,rate,quality,not assumed)
|
| 224 |
+
candidates.append({'url':url,'label':str(label)+((' · '+res) if res else '')+' · '+str(params.get('VCodec') or params.get('v_codec') or 'غير محدد'),'score':score})
|
| 225 |
+
blocks=list(source.get('blocks',[]))
|
| 226 |
+
if source.get('live_core_sdk_data'):blocks.append(source['live_core_sdk_data'])
|
| 227 |
+
for block in blocks:
|
| 228 |
+
try:
|
| 229 |
+
payload=block['pull_data']['stream_data']
|
| 230 |
+
stream=json.loads(payload) if isinstance(payload,str) else payload
|
| 231 |
+
for quality,item in stream.get('data',{}).items():
|
| 232 |
+
main=item.get('main',{})
|
| 233 |
+
try:
|
| 234 |
+
params=main.get('sdk_params') or {}
|
| 235 |
+
if isinstance(params,str):params=json.loads(params)
|
| 236 |
+
except (ValueError,TypeError):params={}
|
| 237 |
+
add(main.get('flv'),quality,params)
|
| 238 |
+
except (KeyError,TypeError,ValueError):pass
|
| 239 |
+
for quality,url in (source.get('flv_pull_url') or {}).items():
|
| 240 |
+
try:params=json.loads((source.get('flv_pull_url_params') or {}).get(quality) or '{}')
|
| 241 |
+
except (ValueError,TypeError):params={}
|
| 242 |
+
add(url,quality,params)
|
| 243 |
+
# Do not assume the generic pull URL is the original feed.
|
| 244 |
+
add(source.get('rtmp_pull_url'),'غير مصنفة',assumed=True)
|
| 245 |
+
result=[];seen=set()
|
| 246 |
+
for item in sorted(candidates,key=lambda c:c['score'],reverse=True):
|
| 247 |
+
if item['url'] not in seen:result.append(item);seen.add(item['url'])
|
| 248 |
+
return result
|
| 249 |
+
|
| 250 |
+
from youtube_manager import ManagedYouTube
|
| 251 |
+
def enabled_accounts():
|
| 252 |
+
with lock:return [u for u,t in tasks.items() if t.get('enabled')]
|
| 253 |
+
youtube=ManagedYouTube(FFMPEG,observe_session,log,enabled_accounts,ROOT/'youtube-api-state.json',headers_for=tiktok_headers)
|
| 254 |
+
|
| 255 |
+
class FLVWriter:
|
| 256 |
+
"""Write complete FLV tags only; arbitrary network chunk boundaries are not packet boundaries."""
|
| 257 |
+
def __init__(self,out):self.out=out;self.buf=bytearray();self.header=False;self.written=0
|
| 258 |
+
def feed(self,chunk):
|
| 259 |
+
self.buf.extend(chunk)
|
| 260 |
+
if not self.header:
|
| 261 |
+
if len(self.buf)<9:return
|
| 262 |
+
if self.buf[:3]!=b'FLV':raise RuntimeError('المصدر لا يعيد FLV صالحًا')
|
| 263 |
+
size=int.from_bytes(self.buf[5:9],'big')+4
|
| 264 |
+
if not 13<=size<=1048576:raise RuntimeError('ترويسة FLV غير صالحة')
|
| 265 |
+
if len(self.buf)<size:return
|
| 266 |
+
self.out.write(self.buf[:size]);del self.buf[:size];self.written+=size;self.header=True
|
| 267 |
+
while len(self.buf)>=11:
|
| 268 |
+
size=11+int.from_bytes(self.buf[1:4],'big')+4
|
| 269 |
+
if len(self.buf)<size:break
|
| 270 |
+
self.out.write(self.buf[:size]);del self.buf[:size];self.written+=size
|
| 271 |
+
|
| 272 |
+
def inspect_media(path):
|
| 273 |
+
r=subprocess.run([FFPROBE,'-v','error','-show_streams','-of','json',str(path)],capture_output=True,text=True,timeout=60)
|
| 274 |
+
if r.returncode:raise RuntimeError('تعذر فحص بيانات الفيديو')
|
| 275 |
+
streams=json.loads(r.stdout).get('streams',[])
|
| 276 |
+
video=next((x for x in streams if x.get('codec_type')=='video' and x.get('width',0)>0),None)
|
| 277 |
+
if not video:raise RuntimeError('لم يوجد مسار فيديو صالح')
|
| 278 |
+
return video
|
| 279 |
+
|
| 280 |
+
def finalize(raw,stem,user):
|
| 281 |
+
mkv=FILES/(stem+'.partial.mkv');mp4=FILES/(stem+'.partial.mp4')
|
| 282 |
+
# SMART: ensure space before heavy remux (needs ~2x file size temporarily)
|
| 283 |
+
smart_cleanup(reason="قبل البناء")
|
| 284 |
+
# Serialize remuxes: recording threads continue, but concurrent disk copies are limited.
|
| 285 |
+
with FINALIZE_LOCK:
|
| 286 |
+
# Double-check free after waiting for lock
|
| 287 |
+
if shutil.disk_usage(FILES).free < ENSURE_FREE_MB*1024*1024:
|
| 288 |
+
smart_cleanup(reason="قبل البناء - طارئ")
|
| 289 |
+
r=subprocess.run([MKVMERGE,'-o',str(mkv),str(raw)],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=3600)
|
| 290 |
+
if r.returncode not in (0,1) or not mkv.exists():raise RuntimeError('تعذر تغليف FLV إلى MKV؛ احتفظنا بالأصل')
|
| 291 |
+
info=inspect_media(mkv)
|
| 292 |
+
codec_args=['-tag:v','hvc1'] if info.get('codec_name')=='hevc' else []
|
| 293 |
+
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)
|
| 294 |
+
if r.returncode==0 and mp4.exists() and mp4.stat().st_size>20000:
|
| 295 |
+
measured=inspect_media(mp4)
|
| 296 |
+
mp4.replace(FILES/(stem+'.mp4'));mkv.unlink(missing_ok=True);raw.unlink(missing_ok=True)
|
| 297 |
+
quality=f"{measured['width']}×{measured['height']} · {measured.get('codec_name','')}"
|
| 298 |
+
if user in tasks:update(user,quality=quality)
|
| 299 |
+
log(user,'تم حفظ MP4 دون إعادة ترميز؛ قياس الملف: '+quality+' (ليس فحص فك ترميز كاملًا)')
|
| 300 |
+
# SMART: never keep mkv if mp4 succeeded (user request)
|
| 301 |
+
# Already deleted mkv above. Also ensure we keep only KEEP_LAST_FILES
|
| 302 |
+
smart_cleanup(reason="بعد البناء")
|
| 303 |
+
else:
|
| 304 |
+
mp4.unlink(missing_ok=True)
|
| 305 |
+
# If mp4 failed but mkv succeeded, keep mkv but user said no need mkv for youtube. Keep mkv only as fallback
|
| 306 |
+
# To respect request "ماله داعي نحفظ بث اليوتيوب الي امتداده mkv" -> delete mkv if we prefer mp4 only? Keep mkv only if mp4 failed
|
| 307 |
+
if AUTO_DELETE_MKV and mkv.exists() and mkv.stat().st_size < 50*1024*1024:
|
| 308 |
+
# small mkv with no mp4? Keep it but log
|
| 309 |
+
pass
|
| 310 |
+
mkv.replace(FILES/(stem+'.mkv'));raw.unlink(missing_ok=True)
|
| 311 |
+
log(user,'تعذر MP4؛ تم حفظ MKV صالح حسب فحص المسارات')
|
| 312 |
+
smart_cleanup(reason="بعد بناء MKV")
|
| 313 |
+
|
| 314 |
+
def record(user,candidate,stop):
|
| 315 |
+
# SMART: pre-check and clean before starting new segment
|
| 316 |
+
if shutil.disk_usage(FILES).free < ENSURE_FREE_MB*1024*1024:
|
| 317 |
+
smart_cleanup(reason="قبل التسجيل")
|
| 318 |
+
if shutil.disk_usage(FILES).free < MIN_FREE_MB*1024**2:
|
| 319 |
+
raise RuntimeError('مساحة نظام الملفات منخفضة — تم التنظيف الذكي وما زالت ممتلئة')
|
| 320 |
+
stem=f'{user}_{time.strftime("%Y%m%d_%H%M%S")}_{secrets.token_hex(2)}'
|
| 321 |
+
raw=FILES/(stem+'.partial.flv');start=time.monotonic();last_update=start
|
| 322 |
+
if user in tasks:update(user,status='جاري الاتصال',quality='مصدر: '+candidate['label'],received_mb=0,elapsed_seconds=0)
|
| 323 |
+
response=requests.get(candidate['url'],impersonate='chrome',headers={'Referer':'https://www.tiktok.com/',**tiktok_headers(user)},stream=True,timeout=20)
|
| 324 |
try:
|
| 325 |
+
response.raise_for_status()
|
| 326 |
+
with open(raw,'wb') as out:
|
| 327 |
+
writer=FLVWriter(out)
|
| 328 |
+
try:
|
| 329 |
+
for chunk in response.iter_content():
|
| 330 |
+
if stop.is_set() or (SEGMENT and time.monotonic()-start>=SEGMENT):break
|
| 331 |
+
if chunk:writer.feed(chunk)
|
| 332 |
+
if time.monotonic()-last_update>=2:
|
| 333 |
+
out.flush();last_update=time.monotonic()
|
| 334 |
+
if user in tasks:update(user,status='تسجيل — وصول بيانات',received_mb=round(writer.written/1048576,1),elapsed_seconds=int(last_update-start))
|
| 335 |
+
if shutil.disk_usage(FILES).free<MIN_FREE_MB*1024**2:
|
| 336 |
+
log(user,'انخفاض المساحة؛ إنهاء الملف');break
|
| 337 |
+
except Exception as e:
|
| 338 |
+
log(user,'انقطع استقبال المصدر؛ محاولة حفظ البيانات المكتملة ('+type(e).__name__+')')
|
| 339 |
+
out.flush()
|
| 340 |
+
finally:response.close()
|
| 341 |
+
if not raw.exists() or raw.stat().st_size<20000:
|
| 342 |
+
raw.unlink(missing_ok=True);raise RuntimeError('لم تصل بيانات فيديو كافية')
|
| 343 |
+
if user in tasks:update(user,status='تجهيز الملف — قد يستغرق وقتًا للبث الطويل')
|
| 344 |
+
try:finalize(raw,stem,user)
|
| 345 |
+
except Exception:
|
| 346 |
+
# Keep the complete-tag raw recording for manual recovery, never silently delete it.
|
| 347 |
+
if raw.exists():raw.replace(FILES/(stem+'.recovery.flv'))
|
| 348 |
+
log(user,'فشل التج��يز؛ احتفظنا بملف recovery.flv للتنزيل والاستعادة')
|
| 349 |
+
raise RuntimeError('فشل تجهيز المصدر؛ سيجرب مصدرًا آخر')
|
| 350 |
+
|
| 351 |
+
def worker(user,stop):
|
| 352 |
+
failures=0
|
| 353 |
+
while not stop.is_set():
|
| 354 |
+
# periodic smart check every loop
|
| 355 |
+
if failures==0:
|
| 356 |
+
# don't spam cleanup every 30s, check free occasionally
|
| 357 |
+
if shutil.disk_usage(FILES).free < ENSURE_FREE_MB*1024*1024:
|
| 358 |
+
smart_cleanup(reason="دوري")
|
| 359 |
+
try:
|
| 360 |
+
update(user,status='فحص البث')
|
| 361 |
+
urls=extract(user)
|
| 362 |
+
if not urls:
|
| 363 |
+
update(user,status='البث منتهٍ');stop.wait(INTERVAL);continue
|
| 364 |
+
update(user,status='جاري الاتصال بمصدر الفيديو');ok=False
|
| 365 |
+
for url in urls:
|
| 366 |
+
if stop.is_set():break
|
| 367 |
+
try:record(user,url,stop);ok=True;break
|
| 368 |
+
except Exception:log(user,'فشل مصدر الفيديو؛ تجربة مصدر آخر إن توفر')
|
| 369 |
+
if not ok and not stop.is_set():raise RuntimeError('فشلت مصادر الفيديو؛ إعادة المحاولة لاحقًا')
|
| 370 |
+
failures=0
|
| 371 |
+
if not stop.is_set():stop.wait(2)
|
| 372 |
+
except Exception as e:
|
| 373 |
+
failures+=1
|
| 374 |
+
# Never display exception URLs/tokens.
|
| 375 |
+
msg=str(e) if isinstance(e,RuntimeError) else 'فشل طلب بيانات البث أو التسجيل (ليس تأكيدًا أن الحساب غير مباشر)'
|
| 376 |
+
update(user,status='انتظار إعادة المحاولة');log(user,msg)
|
| 377 |
+
stop.wait(min(300,INTERVAL*failures))
|
| 378 |
+
update(user,status='متوقف')
|
| 379 |
+
def start(user):
|
| 380 |
+
with lock:
|
| 381 |
+
if user in handles and handles[user]['thread'].is_alive():return
|
| 382 |
+
stop=threading.Event();thread=threading.Thread(target=worker,args=(user,stop),daemon=True)
|
| 383 |
+
handles[user]={'stop':stop,'thread':thread,'process':None};thread.start()
|
| 384 |
+
class Account(BaseModel):
|
| 385 |
+
username:str
|
| 386 |
+
token:str=''
|
| 387 |
+
class TokenBody(BaseModel):
|
| 388 |
+
token:str=''
|
| 389 |
+
@app.get('/',response_class=HTMLResponse)
|
| 390 |
+
def index():return Path(__file__).with_name('index.html').read_text()
|
| 391 |
+
class YouTubeStart(BaseModel):
|
| 392 |
+
channel_id:str
|
| 393 |
+
class YouTubeSettings(BaseModel):
|
| 394 |
+
name:str=''
|
| 395 |
+
privacy:str
|
| 396 |
+
title:str
|
| 397 |
+
description:str
|
| 398 |
+
@app.get('/api/youtube')
|
| 399 |
+
def youtube_state():return youtube.snapshot()
|
| 400 |
+
@app.post('/api/youtube/check')
|
| 401 |
+
def youtube_check():
|
| 402 |
+
try:return {'channel':youtube.check()}
|
| 403 |
+
except Exception as e:
|
| 404 |
+
from youtube_api import APIError
|
| 405 |
+
msg=str(e) if isinstance(e,(APIError,RuntimeError,ValueError)) else 'تعذر فحص تفويض يوتيوب'
|
| 406 |
+
raise HTTPException(400,msg)
|
| 407 |
+
@app.post('/api/youtube/settings')
|
| 408 |
+
def youtube_settings(a:YouTubeSettings):
|
| 409 |
+
try:youtube.settings(a.model_dump())
|
| 410 |
+
except ValueError as e:raise HTTPException(400,str(e))
|
| 411 |
+
return {'ok':True}
|
| 412 |
+
@app.post('/api/youtube/start')
|
| 413 |
+
def youtube_start(a:YouTubeStart):
|
| 414 |
+
try:youtube.enable(a.channel_id)
|
| 415 |
+
except Exception as e:
|
| 416 |
+
from youtube_api import APIError
|
| 417 |
+
msg=str(e) if isinstance(e,(APIError,RuntimeError,ValueError)) else 'تعذر بدء أتمتة يوتيوب'
|
| 418 |
+
raise HTTPException(400,msg)
|
| 419 |
+
return {'ok':True}
|
| 420 |
+
@app.post('/api/youtube/stop')
|
| 421 |
+
def youtube_stop():
|
| 422 |
+
youtube.disable();return {'ok':True}
|
| 423 |
+
|
| 424 |
+
@app.get('/api/state')
|
| 425 |
+
def state():
|
| 426 |
+
with lock:
|
| 427 |
+
disk=shutil.disk_usage(FILES)
|
| 428 |
+
# trigger smart view but don't delete here - just report
|
| 429 |
+
files=[{'name':p.name,'mb':round(p.stat().st_size/1048576,1)} for p in sorted(FILES.iterdir(),reverse=True) if p.suffix in ('.mp4','.ts','.mkv','.flv') and '.partial.' not in p.name]
|
| 430 |
+
task_list=[{'username':u,**t,'has_token':bool(get_token(u))} for u,t in tasks.items()]
|
| 431 |
+
tiktok_state=tiktok_auth.describe([t['username'] for t in task_list])
|
| 432 |
+
free_mb = round(disk.free/1048576,1)
|
| 433 |
+
return {'tasks':task_list,'logs':events[-60:],'files':files,'version':'V9-smart','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}
|
| 434 |
+
@app.post('/api/accounts')
|
| 435 |
+
def add(a:Account):
|
| 436 |
+
try:u=clean_user(a.username)
|
| 437 |
+
except ValueError as e:raise HTTPException(400,str(e))
|
| 438 |
+
with lock:
|
| 439 |
+
if MAX_ACCOUNTS is not None and u not in tasks and len(tasks)>=MAX_ACCOUNTS:raise HTTPException(400,'بلغت حد الموارد المحدد؛ يمكنك تغييره عبر MAX_ACCOUNTS')
|
| 440 |
+
tasks.setdefault(u,{'enabled':True,'status':'بدء التشغيل'});tasks[u]['enabled']=True;save();start(u)
|
| 441 |
+
if a.token and a.token.strip():set_token(u,a.token)
|
| 442 |
+
return {'ok':True}
|
| 443 |
+
@app.post('/api/accounts/{user}/token')
|
| 444 |
+
def account_token(user:str,b:TokenBody):
|
| 445 |
+
with lock:
|
| 446 |
+
if user not in tasks:raise HTTPException(404)
|
| 447 |
+
set_token(user,b.token)
|
| 448 |
+
return {'ok':True,'has_token':bool(get_token(user)),'tiktok':tiktok_auth.describe([user])}
|
| 449 |
+
|
| 450 |
+
@app.get('/api/tiktok/token')
|
| 451 |
+
def tiktok_token_state():
|
| 452 |
+
"""Masked token state only — never the token itself."""
|
| 453 |
+
with lock:users=list(tasks.keys())
|
| 454 |
+
return tiktok_auth.describe(users)
|
| 455 |
+
|
| 456 |
+
@app.post('/api/tiktok/token/validate')
|
| 457 |
+
def tiktok_token_validate(b:TokenBody):
|
| 458 |
+
"""Ask TikTok whether the pasted token is alive. A rejected token is not saved."""
|
| 459 |
+
raw=(b.token or '').strip()
|
| 460 |
+
if not raw:raise HTTPException(400,'الصق التوكن أولًا')
|
| 461 |
+
report=tiktok_auth.validate(raw)
|
| 462 |
+
if report.get('ok'):
|
| 463 |
+
# Only a token TikTok accepted reaches the store.
|
| 464 |
+
try:tiktok_auth.save('',raw)
|
| 465 |
+
except ValueError as e:raise HTTPException(400,str(e))
|
| 466 |
+
log('system','حُفظ توكن تيك توك %s — سيُستخدم للرصد والتنزيل'%report.get('sessionid_masked',''))
|
| 467 |
+
else:
|
| 468 |
+
log('system','تيك توك رفض التوكن؛ لم يُحفظ')
|
| 469 |
+
return report
|
| 470 |
+
|
| 471 |
+
@app.post('/api/tiktok/token/check/{user}')
|
| 472 |
+
def tiktok_token_check(user:str):
|
| 473 |
+
"""Live proof: does this account's room yield video URLs with vs without the token?"""
|
| 474 |
+
try:return tiktok_auth.unlock_check(user)
|
| 475 |
+
except Exception as e:
|
| 476 |
+
raise HTTPException(400,str(e) if isinstance(e,(ValueError,RuntimeError)) else 'تعذر اختبار فتح الحساب')
|
| 477 |
+
|
| 478 |
+
@app.delete('/api/tiktok/token/{user}')
|
| 479 |
+
def tiktok_token_delete(user:str):
|
| 480 |
+
with lock:
|
| 481 |
+
_tokens.pop(user,None);save_tokens()
|
| 482 |
+
return {'ok':tiktok_auth.delete(user)}
|
| 483 |
+
@app.post('/api/accounts/{user}/{action}')
|
| 484 |
+
def action(user:str,action:str):
|
| 485 |
+
with lock:
|
| 486 |
+
if user not in tasks:raise HTTPException(404)
|
| 487 |
+
if action=='start':
|
| 488 |
+
if user in handles and handles[user]['thread'].is_alive() and handles[user]['stop'].is_set():raise HTTPException(409,'انتظر اكتمال إيقاف المقطع')
|
| 489 |
+
tasks[user]['enabled']=True;start(user)
|
| 490 |
+
elif action=='stop':
|
| 491 |
+
tasks[user]['enabled']=False
|
| 492 |
+
if user in handles:handles[user]['stop'].set()
|
| 493 |
+
elif action=='delete':
|
| 494 |
+
if user in handles and handles[user]['thread'].is_alive():raise HTTPException(409,'أوقف الحساب وانتظر انتهاء الحفظ قبل حذفه')
|
| 495 |
+
del tasks[user]
|
| 496 |
+
_tokens.pop(user,None);save_tokens()
|
| 497 |
+
else:raise HTTPException(400)
|
| 498 |
+
save()
|
| 499 |
+
return {'ok':True}
|
| 500 |
+
def safe_file(name):
|
| 501 |
+
p=(FILES/name).resolve()
|
| 502 |
+
if p.parent!=FILES or '.partial.' in p.name or p.suffix not in ('.ts','.mp4','.mkv','.flv') or not p.is_file():raise HTTPException(404)
|
| 503 |
+
return p
|
| 504 |
+
@app.get('/api/files/{name}')
|
| 505 |
+
def download(name:str):return FileResponse(safe_file(name),filename=name)
|
| 506 |
+
@app.delete('/api/files/{name}')
|
| 507 |
+
def delete(name:str):safe_file(name).unlink();return {'ok':True}
|
| 508 |
+
@app.on_event('startup')
|
| 509 |
+
def startup():
|
| 510 |
+
load_tokens()
|
| 511 |
+
# smart cleanup on startup to guarantee space
|
| 512 |
try:
|
| 513 |
+
smart_cleanup(reason="بدء التشغيل")
|
| 514 |
+
except Exception:
|
| 515 |
pass
|
| 516 |
+
try:saved=json.loads((ROOT/'tasks.json').read_text())
|
| 517 |
+
except FileNotFoundError:saved={}
|
| 518 |
+
except (OSError,ValueError):
|
| 519 |
+
logging.exception('Unable to restore task settings')
|
| 520 |
+
log('system','تعذر قراءة الحسابات المحفوظة؛ تحقق من التخزين قبل إضافة حسابات')
|
| 521 |
+
saved={}
|
| 522 |
+
if not isinstance(saved,dict):
|
| 523 |
+
log('system','صيغة ملف الحسابات غير صالحة');saved={}
|
| 524 |
+
saved_items=list(saved.items())
|
| 525 |
+
if MAX_ACCOUNTS is not None:saved_items=saved_items[:MAX_ACCOUNTS]
|
| 526 |
+
for u,enabled in saved_items:
|
| 527 |
+
try:u=clean_user(u)
|
| 528 |
+
except ValueError:continue
|
| 529 |
+
tasks[u]={'enabled':bool(enabled),'status':'متوقف'}
|
| 530 |
+
if enabled:start(u)
|
| 531 |
+
youtube.restore()
|
| 532 |
+
@app.on_event('shutdown')
|
| 533 |
+
def shutdown():
|
| 534 |
+
youtube.stop()
|
| 535 |
+
for h in list(handles.values()):h['stop'].set()
|
| 536 |
+
deadline=time.monotonic()+50
|
| 537 |
+
for h in list(handles.values()):h['thread'].join(timeout=max(0,deadline-time.monotonic()))
|
| 538 |
+
|
| 539 |
+
# periodic smart janitor every 10 minutes
|
| 540 |
+
def janitor_loop():
|
| 541 |
while True:
|
| 542 |
+
time.sleep(600)
|
| 543 |
try:
|
| 544 |
+
smart_cleanup(reason="دوري كل 10 دقايق")
|
| 545 |
+
except Exception:
|
| 546 |
+
pass
|
| 547 |
+
threading.Thread(target=janitor_loop, daemon=True).start()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
flv_bridge.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
import os
|
| 25 |
+
|
| 26 |
+
class FormatChanged(Exception):
|
| 27 |
+
"""Source video decoder configuration changed mid-stream."""
|
| 28 |
+
|
| 29 |
+
class TimebaseReset(Exception):
|
| 30 |
+
"""Source timestamps jumped backwards: TikTok switched CDN edge or restarted the stream clock.
|
| 31 |
+
|
| 32 |
+
With `-c:v copy` the encoder session cannot be re-timed: the muxer keeps the old timeline, so
|
| 33 |
+
the picture repeats the last frames and roughly half of the audio packets are dropped. Only a
|
| 34 |
+
fresh FFmpeg session (new RTMPS connection, new FLV header, timestamps starting clean) fixes it.
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
def _video_signature(body):
|
| 38 |
+
"""Fingerprint of a video sequence header, or None if `body` is not one."""
|
| 39 |
+
if not body:return None
|
| 40 |
+
if body[0]&0x80: # Enhanced RTMP
|
| 41 |
+
if body[0]&0x0F:return None # PacketType 0 == sequence header
|
| 42 |
+
return bytes(body[1:5])+body[5:] # FourCC + decoder config record
|
| 43 |
+
if (body[0]&15)==7 and len(body)>1 and body[1]==0: # legacy AVC sequence header
|
| 44 |
+
return b'avc1'+body[5:]
|
| 45 |
+
return None
|
| 46 |
+
|
| 47 |
+
class FLVBridge:
|
| 48 |
+
def __init__(self,detect_format_change=True,detect_timebase_reset=True,reset_tolerance_ms=None):
|
| 49 |
+
self.buffer=bytearray();self.header_done=False;self.converted_tags=0
|
| 50 |
+
self.detect_format_change=detect_format_change
|
| 51 |
+
self.video_cfg=None;self.format_changes=0
|
| 52 |
+
# Timestamp-reset guard: a backwards jump larger than the tolerance means the source
|
| 53 |
+
# restarted its clock mid-stream. Default 500 ms, override with FLV_TS_RESET_TOL.
|
| 54 |
+
self.detect_timebase_reset=detect_timebase_reset
|
| 55 |
+
self.reset_tolerance_ms=int(reset_tolerance_ms if reset_tolerance_ms is not None
|
| 56 |
+
else os.getenv('FLV_TS_RESET_TOL','500'))
|
| 57 |
+
self.last_ts=None;self.timebase_resets=0
|
| 58 |
+
def feed(self,chunk):
|
| 59 |
+
self.buffer.extend(chunk);output=bytearray()
|
| 60 |
+
if not self.header_done:
|
| 61 |
+
if len(self.buffer)<9:return b''
|
| 62 |
+
if self.buffer[:3]!=b'FLV':raise ValueError('Source is not FLV')
|
| 63 |
+
header_size=int.from_bytes(self.buffer[5:9],'big')+4
|
| 64 |
+
if not 13<=header_size<=1048576:raise ValueError('Invalid FLV header size')
|
| 65 |
+
if len(self.buffer)<header_size:return b''
|
| 66 |
+
output.extend(self.buffer[:header_size]);del self.buffer[:header_size];self.header_done=True
|
| 67 |
+
offset=0
|
| 68 |
+
while len(self.buffer)-offset>=11:
|
| 69 |
+
header=bytearray(self.buffer[offset:offset+11]);size=int.from_bytes(header[1:4],'big');total=11+size+4
|
| 70 |
+
if len(self.buffer)-offset<total:break
|
| 71 |
+
body=bytes(self.buffer[offset+11:offset+11+size]);tag_type=header[0]
|
| 72 |
+
if tag_type & 0x20:raise ValueError('Filtered/encrypted FLV tag is unsupported')
|
| 73 |
+
# Timestamp-reset detection on the raw tag header, before any conversion. Audio (8)
|
| 74 |
+
# and video (9) share one FLV timeline; a healthy stream only ever moves forward, so a
|
| 75 |
+
# backwards jump past the tolerance is the source restarting its clock.
|
| 76 |
+
if self.detect_timebase_reset and (tag_type & 31) in (8,9):
|
| 77 |
+
ts=int.from_bytes(header[4:7],'big')+(header[7]<<24)
|
| 78 |
+
if self.last_ts is not None and ts < self.last_ts-self.reset_tolerance_ms:
|
| 79 |
+
self.timebase_resets+=1
|
| 80 |
+
raise TimebaseReset('timestamp went backwards %d ms -> %d ms'%(self.last_ts,ts))
|
| 81 |
+
if self.last_ts is None or ts>self.last_ts:self.last_ts=ts
|
| 82 |
+
if (tag_type & 31)==9 and body and not (body[0]&0x80) and (body[0]&15)==12:
|
| 83 |
+
if len(body)<5:raise ValueError('Truncated HEVC FLV video header')
|
| 84 |
+
frame=(body[0]>>4)&7;packet=body[1]
|
| 85 |
+
if packet==0: # HEVC configuration record, no composition time in SequenceStart
|
| 86 |
+
body=bytes([0x80|(frame<<4)])+b'hvc1'+body[5:]
|
| 87 |
+
elif packet==1: # CodedFrames includes the same signed 24-bit composition offset
|
| 88 |
+
body=bytes([0x80|(frame<<4)|1])+b'hvc1'+body[2:]
|
| 89 |
+
elif packet==2:
|
| 90 |
+
body=bytes([0x80|(frame<<4)|2])+b'hvc1'+body[5:]
|
| 91 |
+
else:raise ValueError('Unsupported legacy HEVC packet type')
|
| 92 |
+
self.converted_tags+=1
|
| 93 |
+
if (tag_type & 31)==9:
|
| 94 |
+
signature=_video_signature(body)
|
| 95 |
+
if signature is not None:
|
| 96 |
+
if self.video_cfg is None:self.video_cfg=signature
|
| 97 |
+
elif signature!=self.video_cfg:
|
| 98 |
+
self.format_changes+=1
|
| 99 |
+
if self.detect_format_change:
|
| 100 |
+
raise FormatChanged('video decoder configuration changed mid-stream')
|
| 101 |
+
self.video_cfg=signature
|
| 102 |
+
header[1:4]=len(body).to_bytes(3,'big')
|
| 103 |
+
output.extend(header);output.extend(body);output.extend((11+len(body)).to_bytes(4,'big'))
|
| 104 |
+
offset+=total
|
| 105 |
+
if offset:del self.buffer[:offset]
|
| 106 |
+
if len(self.buffer)>17*1024*1024:raise ValueError('FLV packet buffer exceeds limit')
|
| 107 |
+
return bytes(output)
|
index.html
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html><html lang="ar" dir="rtl"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>قصي • مدير البثوث</title><style>
|
| 2 |
+
:root{color-scheme:dark;font-family:system-ui}body{margin:0;background:#0c1220;color:#e4eaf7}main{max-width:1100px;margin:40px auto;padding:0 20px}header{display:flex;justify-content:space-between;align-items:center}h1{font-size:32px;margin:0}p{color:#9daec8;line-height:1.8}small{color:#9daec8}.badge{border:1px solid #355445;border-radius:30px;padding:8px 15px;color:#78e1b7}.panel{background:#151e30;border:1px solid #28364f;padding:24px;border-radius:18px;margin:22px 0}input,button{font:inherit;border-radius:10px;padding:12px;border:1px solid #364864}input{background:#0c1220;color:white;flex:1;min-width:100px}button{background:#70e1b4;color:#10251d;cursor:pointer;font-weight:650}button.secondary{background:#23324b;color:#d4e0f5}button.danger{background:#452735;color:#ffbccb}form{display:flex;gap:12px;flex-wrap:wrap}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px}.card{border:1px solid #32405a;border-radius:12px;padding:18px}.card h3{direction:ltr;text-align:right}.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.file{padding:14px 0;border-bottom:1px solid #29364c;display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap}a{color:#79dcc5}#logs{max-height:280px;overflow:auto;font:14px/1.9 monospace}#error{color:#ffc1c1}h2{font-size:20px}footer{color:#8e9eb6;font-size:13px;margin:30px 0}code{overflow-wrap:anywhere}button:disabled{opacity:.5}
|
| 3 |
+
|
| 4 |
+
textarea,select{font:inherit;background:#0c1220;color:#e4eaf7;border:1px solid #364864;border-radius:10px;padding:12px;box-sizing:border-box;width:100%}label{display:block;margin:14px 0 6px;color:#becce2}.settings{display:grid;grid-template-columns:1fr 1fr;gap:18px}.settings input{width:100%;box-sizing:border-box}.wide{grid-column:1/-1}.notice{border-right:3px solid #70e1b4;padding:10px 15px;background:#142b2b}#ytstate{white-space:pre-line}button:disabled{cursor:wait} @media(max-width:650px){.settings{grid-template-columns:1fr}header{gap:10px}.wide{grid-column:auto}}
|
| 5 |
+
</style></head><body><main>
|
| 6 |
+
<header><div><small>V9 • بث متزامن لكل مذيع • إصلاح تغيّر أبعاد الصورة • توكن تيك توك للبثوث المقيدة عمريًا</small><h1>قصي مهاجر عراقي</h1></div><span class="badge">مرصد البثوث</span></header>
|
| 7 |
+
<p>تسجيل أصلي مستقل لكل حساب • حدث يوتيوب مستقل لكل مذيع مباشر يبث بنفس الوقت • اسم المذيع وعنوان البث والهاشتاكات تُجلب تلقائيًا من حسابه</p>
|
| 8 |
+
<div id="error" role="alert" class="notice" hidden></div>
|
| 9 |
+
<section class="panel"><h2>الحسابات التابعة للمذيع</h2>
|
| 10 |
+
<form id="add"><input id="username" placeholder="اسم المستخدم أو رابط الحساب الكامل" required><input id="token" placeholder="توكن الجلسة sessionid — اختياري، للبثوث المقيدة عمريًا"><button>إضافة ومراقبة</button></form>
|
| 11 |
+
<p id="capacity"></p><div id="tasks" class="cards"></div>
|
| 12 |
+
<p class="notice">التوكن هو كوكيز جلسة تيك توك لحساب بالغ، يُستخدم فقط لفتح البثوث المقيدة بفئة عمرية. يُخزن في مجلد البيانات ولا يظهر في الواجهة أو السجلات. استخدم حسابًا مخصصًا له.</p></section>
|
| 13 |
+
|
| 14 |
+
<section class="panel"><h2>توكن تيك توك — للبثوث المقيدة عمريًا (18+)</h2>
|
| 15 |
+
<p>تيك توك يخفي روابط الفيديو عن الزائر غير المسجّل في البثوث المقيّدة عمريًا، فلا يمكن رصدها ولا تسجيلها. التوكن يفتحها.</p>
|
| 16 |
+
<label for="tokinput">الصق <b>كامل سطر Cookie</b> (أو قيمة <code dir="ltr">sessionid</code> وحدها)</label>
|
| 17 |
+
<textarea id="tokinput" rows="3" dir="ltr" placeholder="sessionid=...; sessionid_ss=...; sid_tt=...; tt-target-idc=useast1a; sid_guard=..."></textarea>
|
| 18 |
+
<div class="row" style="margin-top:12px">
|
| 19 |
+
<button id="tokvalidate" class="secondary">١. فحص التوكن عند تيك توك</button>
|
| 20 |
+
<button id="toksave">٢. فحصه وحفظه لكل الحسابات</button>
|
| 21 |
+
</div>
|
| 22 |
+
<div class="row" style="margin-top:10px">
|
| 23 |
+
<input id="tokuser" dir="ltr" placeholder="اسم حساب مقيد عمريًا (مثال: user.name)">
|
| 24 |
+
<button id="tokcheck" class="secondary">٣. اختبر فتح الحساب</button>
|
| 25 |
+
</div>
|
| 26 |
+
<p id="tokresult" class="notice" hidden></p>
|
| 27 |
+
<div id="tokstate" style="margin-top:14px"></div>
|
| 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><small>تظهر بعد اكتمال تجهيز الملف. HEVC قد يحتاج VLC. لا تقسيم زمني افتراضيًا؛ قد تظهر ملفات منفصلة عند الانقطاع.</small><div id="files"></div></section>
|
| 47 |
+
<section class="panel"><h2>سجل التشغيل</h2><div id="logs"></div></section>
|
| 48 |
+
<footer>أسرار OAuth وتوكنات الجلسات تبقى في Secrets ومجلد البيانات فقط، ولا تظهر في الواجهة. راقب حصة التخزين من Hugging Face. تقدير API محلي وليس قراءة لحصة Google الفعلية. استخدمه للمحتوى الذي تملك حق إعادة نشره.</footer>
|
| 49 |
+
</main><script>
|
| 50 |
+
const $=id=>document.getElementById(id),esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
| 51 |
+
let settingsLoaded=false,channelId='',lastY=null,polling=false;
|
| 52 |
+
function error(text){$('error').textContent=text;$('error').hidden=!text}
|
| 53 |
+
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}
|
| 54 |
+
function fillSettings(s){$('privacy').value=s.privacy||'public';$('titletemplate').value=s.title||'';$('desctemplate').value=s.description||'';settingsLoaded=true}
|
| 55 |
+
async function refresh(){if(polling)return;polling=true;try{
|
| 56 |
+
const [y,s]=await Promise.all([api('/api/youtube'),api('/api/state')]);lastY=y;
|
| 57 |
+
const evs=y.events||[];
|
| 58 |
+
$('capacity').textContent=`${s.version} • حد الحسابات: ${s.max_accounts} • أقصى أحداث يوتيوب متزامنة: ${y.max_events||3}`;
|
| 59 |
+
$('channel').textContent=y.channel?`القناة المرتبطة: ${y.channel.title} · ${y.channel.id}`:'اضغط فحص OAuth للتأكد من القناة قبل النشر.';
|
| 60 |
+
channelId=y.channel?.id||'';
|
| 61 |
+
$('ytstate').textContent=`${y.automatic?'الإدارة التلقائية مفعّلة':'الإدارة التلقائية معطّلة'} · وضع المصدر: ${y.video_mode||'original'} · وضع الإرسال: ${y.send_mode||'copy'} · أحداث جارية: ${evs.length}\n${y.status}`;
|
| 62 |
+
$('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>';
|
| 63 |
+
$('budget').textContent=`استهلاك API محلي تقديري: ${y.budget?.estimated_units||0} وحدة • جلسات في سجل التطبيق: ${y.history_count||0} • ${y.configured?'أسرار OAuth موجودة (الفحص يؤكد صلاحيتها)':'أسرار OAuth غير مكتملة'}`;
|
| 64 |
+
$('ytstart').disabled=!!y.automatic;$('ytstop').disabled=!y.automatic&&!evs.length;
|
| 65 |
+
$('savesettings').disabled=!!y.automatic||!!evs.length;
|
| 66 |
+
if(!settingsLoaded&&y.settings)fillSettings(y.settings);
|
| 67 |
+
$('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>';
|
| 68 |
+
$('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>';
|
| 69 |
+
$('logs').innerHTML=s.logs.slice().reverse().map(l=>`<div>${esc(l.time)} · @${esc(l.user)} · ${esc(l.message)}</div>`).join('');
|
| 70 |
+
renderTiktok(s.tiktok);
|
| 71 |
+
}catch(e){error(e.message)}finally{polling=false}}
|
| 72 |
+
function expiryNote(e){if(!e)return '';
|
| 73 |
+
if(e.expired)return `<br><small style="color:#ffc1c1">⚠ انتهت صلاحيته ${esc(e.expires_text)} — حدّثه</small>`;
|
| 74 |
+
const h=Math.floor(e.seconds_left/3600),m=Math.floor((e.seconds_left%3600)/60);
|
| 75 |
+
const warn=e.seconds_left<3600;
|
| 76 |
+
return `<br><small${warn?' style="color:#ffd9a0"':''}>${warn?'⚠ ':''}ينتهي بعد ${h}س ${m}د (${esc(e.expires_text)})</small>`}
|
| 77 |
+
function renderTiktok(t){if(!t){$('tokstate').innerHTML='';return}
|
| 78 |
+
const accts=Object.entries(t.accounts||{});
|
| 79 |
+
let rows=`<div class="file"><div>توكن عام (كل الحسابات)</div><div>${t.default_authenticated?`<span class="badge">مفعّل</span> ${esc(t.default_masked)} <small>· ${esc(t.default_source||'')}</small>${expiryNote(t.default_expiry)}`:'<small>غير مضبوط</small>'}</div></div>`;
|
| 80 |
+
rows+=accts.length?accts.map(([u,v])=>`<div class="file"><div>@${esc(u)}</div><div>${v.authenticated?`<span class="badge">له توكن</span> ${esc(v.masked)} <small>· ${esc(v.source||'')}${v.tt_target_idc?' · '+esc(v.tt_target_idc):''}</small>${expiryNote(v.expiry)}`:'<small>يعتمد على التوكن العام</small>'}</div></div>`).join(''):'';
|
| 81 |
+
$('tokstate').innerHTML=rows}
|
| 82 |
+
function tokShow(html){const e=$('tokresult');e.innerHTML=html;e.hidden=false}
|
| 83 |
+
$('tokvalidate').onclick=async()=>{const raw=$('tokinput').value.trim();if(!raw){tokShow('✖ الصق التوكن أولًا');return}
|
| 84 |
+
try{error('');$('tokvalidate').disabled=true;tokShow('… يسأل تيك توك');
|
| 85 |
+
const r=await api('/api/tiktok/token/validate','POST',{token:raw});
|
| 86 |
+
tokShow(`<b>${r.ok?'✔ ':'✖ '}${esc(r.message||'')}</b>`+(r.checks?.length?`<br><small>${r.checks.map(esc).join('<br>')}</small>`:'')+(r.account?`<br><small dir="ltr">${esc(JSON.stringify(r.account))}</small>`:''));
|
| 87 |
+
if(r.ok)await refresh()}catch(e){tokShow('✖ '+esc(e.message))}finally{$('tokvalidate').disabled=false}};
|
| 88 |
+
$('toksave').onclick=async()=>{const raw=$('tokinput').value.trim();if(!raw){tokShow('✖ الصق التوكن أولًا');return}
|
| 89 |
+
try{error('');$('toksave').disabled=true;tokShow('… فحص ثم حفظ');
|
| 90 |
+
const r=await api('/api/tiktok/token/validate','POST',{token:raw});
|
| 91 |
+
if(!r.ok){tokShow(`✖ لم يُحفظ — ${esc(r.message||'')}`+(r.checks?.length?`<br><small>${r.checks.map(esc).join('<br>')}</small>`:''));return}
|
| 92 |
+
tokShow(`<b>✔ حُفظ ويعمل</b><br><small>${esc(r.message||'')} · ${esc(r.sessionid_masked||'')}</small>`);
|
| 93 |
+
$('tokinput').value='';await refresh()}catch(e){tokShow('✖ '+esc(e.message))}finally{$('toksave').disabled=false}};
|
| 94 |
+
$('tokcheck').onclick=async()=>{const u=$('tokuser').value.trim();if(!u){tokShow('✖ اكتب اسم الحساب');return}
|
| 95 |
+
try{error('');$('tokcheck').disabled=true;tokShow('… يختبر فتح الحساب عند تيك توك');
|
| 96 |
+
const r=await api('/api/tiktok/token/check/'+encodeURIComponent(u),'POST');
|
| 97 |
+
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}};
|
| 98 |
+
$('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}};
|
| 99 |
+
$('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)}};
|
| 100 |
+
$('ytstop').onclick=async()=>{if(!confirm('تعطيل الإدارة وإنهاء جميع الأحداث الجارية مع الاحتفاظ بالأرشيف؟'))return;try{error('');await api('/api/youtube/stop','POST');await refresh()}catch(e){error(e.message)}};
|
| 101 |
+
$('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)}};
|
| 102 |
+
$('add').onsubmit=async e=>{e.preventDefault();try{error('');await api('/api/accounts','POST',{username:$('username').value,token:$('token').value});$('username').value='';$('token').value='';await refresh()}catch(e){error(e.message)}};
|
| 103 |
+
document.addEventListener('click',async e=>{const b=e.target.closest('button');if(!b)return;try{
|
| 104 |
+
if(b.dataset.action==='token'){const t=prompt(`توكن جلسة تيك توك (sessionid) للحساب @${b.dataset.user} — يلزم للبثوث المقيدة بفئة عمرية.\nالصق قيمة sessionid أو كوكيز كاملة، أو اتركه فارغًا للحذف:`);if(t===null)return;await api(`/api/accounts/${encodeURIComponent(b.dataset.user)}/token`,'POST',{token:t});await refresh();return}
|
| 105 |
+
if(b.dataset.action){if(b.dataset.action==='delete'&&!confirm('حذف الحساب من المراقبة؟'))return;await api(`/api/accounts/${encodeURIComponent(b.dataset.user)}/${b.dataset.action}`,'POST');await refresh()}
|
| 106 |
+
if(b.dataset.file&&confirm('حذف التسجيل نهائيًا من التخزين؟')){await api('/api/files/'+encodeURIComponent(b.dataset.file),'DELETE');await refresh()}}catch(e){error(e.message)}});
|
| 107 |
+
document.addEventListener('visibilitychange',()=>{if(!document.hidden)refresh()});window.addEventListener('online',refresh);window.addEventListener('pageshow',refresh);refresh();setInterval(refresh,5000);
|
| 108 |
+
</script></body></html>
|
relay.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, TimebaseReset
|
| 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 |
+
# Enabled by default: a source header change mid-stream (PK round start/end, app resumed)
|
| 118 |
+
# leaves YouTube relocked on the old size with `-c:v copy` -> zoom/stutter/freeze/black.
|
| 119 |
+
# The guard turns that into a clean 1-3 s reconnection instead. Set to 0 to disable.
|
| 120 |
+
guard=os.getenv('YOUTUBE_FORMAT_GUARD','1').lower() in ('1','on','true','yes')
|
| 121 |
+
bridge=FLVBridge(detect_format_change=(self.send_mode()=='copy' and guard))
|
| 122 |
+
response=None;p=None;error_reader=None;done=threading.Event()
|
| 123 |
+
# Set when we deliberately tear FFmpeg down (source resolution change or a source clock
|
| 124 |
+
# reset); suppresses the misleading "FFmpeg exited with code N" log.
|
| 125 |
+
expected_teardown=False
|
| 126 |
+
progress={'last':time.monotonic(),'seconds':-1}
|
| 127 |
+
self.set(status='الاتصال بيوتيوب',source=candidate['label'],output_seconds=0,last_output_at=None)
|
| 128 |
+
try:
|
| 129 |
+
response=requests.get(candidate['url'],headers={'Referer':'https://www.tiktok.com/',**self.headers_for(user)},impersonate='chrome',stream=True,timeout=20)
|
| 130 |
+
response.raise_for_status()
|
| 131 |
+
p=subprocess.Popen(self.command(destination),stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,bufsize=0)
|
| 132 |
+
with self.lock:self.process=p
|
| 133 |
+
diagnostics=[]
|
| 134 |
+
def read_errors():
|
| 135 |
+
# Classify selected FFmpeg errors; never expose raw stderr or stream URLs.
|
| 136 |
+
patterns=[('Connection refused','رفض اتصال خادم الاستقبال'),('Connection timed out','انتهت مهلة اتصال خادم الاستقبال'),
|
| 137 |
+
('Connection reset by peer','خادم الاستقبال قطع الاتصال أثناء الإرسال'),('Broken pipe','انقطع أنبوب الإرسال إلى خادم الاستقبال'),
|
| 138 |
+
('End of file','خادم الاستقبال أغلق الاتصال'),('Immediate exit requested','طُلب إنهاء FFmpeg قبل اكتمال الإرسال'),
|
| 139 |
+
('Packet mismatch','عدم تطابق حزم الفيديو — تغيّر مقاس المصدر داخل الجلسة'),
|
| 140 |
+
('Input/output error','خطأ إدخال/إخراج في الإرسال'),('Server error','خادم الاستقبال أعاد خطأ'),
|
| 141 |
+
('Invalid data found','بيانات المصدر غير صالحة للقراءة'),('Video codec','عدم توافق ترميز الفيديو مع الحاوية'),
|
| 142 |
+
('Invalid argument','أحد إعدادات FFmpeg غير مقبول'),('Non-monotonic','توقيت الفيديو غير متزايد'),
|
| 143 |
+
('Unknown encoder','مرمز غير متوفر'),('Option not found','خيار FFmpeg غير مدعوم')]
|
| 144 |
+
try:
|
| 145 |
+
for line in iter(p.stderr.readline,b''):
|
| 146 |
+
text=line.decode('utf-8','replace')
|
| 147 |
+
for needle,label in patterns:
|
| 148 |
+
if needle.lower() in text.lower() and label not in diagnostics:diagnostics.append(label)
|
| 149 |
+
except (OSError,ValueError):pass
|
| 150 |
+
error_reader=threading.Thread(target=read_errors,daemon=True);error_reader.start()
|
| 151 |
+
def read_progress():
|
| 152 |
+
try:
|
| 153 |
+
for line in iter(p.stdout.readline,b''):
|
| 154 |
+
if line.startswith(b'out_time_us='):
|
| 155 |
+
try:seconds=int(line.split(b'=',1)[1])/1000000
|
| 156 |
+
except ValueError:continue
|
| 157 |
+
if seconds>progress['seconds']:
|
| 158 |
+
progress['seconds']=seconds;progress['last']=time.monotonic()
|
| 159 |
+
self.set(status='FFmpeg يرسل — تحقق من معاينة YouTube Studio',output_seconds=round(seconds,1),last_output_at=time.time())
|
| 160 |
+
except (OSError,ValueError):pass
|
| 161 |
+
def watchdog():
|
| 162 |
+
while not done.wait(1):
|
| 163 |
+
if stop.is_set() or time.monotonic()-progress['last']>60:
|
| 164 |
+
if p.poll() is None:
|
| 165 |
+
try:p.kill()
|
| 166 |
+
except OSError:pass
|
| 167 |
+
return
|
| 168 |
+
threading.Thread(target=read_progress,daemon=True).start()
|
| 169 |
+
threading.Thread(target=watchdog,daemon=True).start()
|
| 170 |
+
for chunk in response.iter_content():
|
| 171 |
+
if stop.is_set() or p.poll() is not None:break
|
| 172 |
+
if chunk:
|
| 173 |
+
# Unbuffered writes may be partial; send every byte.
|
| 174 |
+
view=memoryview(bridge.feed(chunk))
|
| 175 |
+
while view and not stop.is_set():
|
| 176 |
+
n=p.stdin.write(view)
|
| 177 |
+
if not n:raise BrokenPipeError()
|
| 178 |
+
view=view[n:]
|
| 179 |
+
except (FormatChanged,TimebaseReset):
|
| 180 |
+
expected_teardown=True
|
| 181 |
+
raise
|
| 182 |
+
finally:
|
| 183 |
+
if response:response.close()
|
| 184 |
+
if p:
|
| 185 |
+
try:p.stdin.close()
|
| 186 |
+
except (OSError,ValueError):pass
|
| 187 |
+
try:p.wait(timeout=10)
|
| 188 |
+
except subprocess.TimeoutExpired:p.kill();p.wait()
|
| 189 |
+
done.set()
|
| 190 |
+
if error_reader:error_reader.join(timeout=1)
|
| 191 |
+
if p.returncode and not stop.is_set() and not expected_teardown:
|
| 192 |
+
self.log(user,'FFmpeg خرج برمز '+str(p.returncode)+(' — '+'؛ '.join(diagnostics[-3:]) if diagnostics else '؛ لم يظهر سبب مصنّف'))
|
| 193 |
+
with self.lock:self.process=None
|
| 194 |
+
return progress['seconds']>0
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.115,<1
|
| 2 |
+
uvicorn>=0.30,<1
|
| 3 |
+
curl_cffi>=0.13,<1
|
tiktok_auth.py
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""TikTok session-token support (Secrets/env vars + optional on-volume store).
|
| 2 |
+
|
| 3 |
+
Why this exists
|
| 4 |
+
---------------
|
| 5 |
+
Age-restricted (18+) TikTok LIVE rooms return **no stream URLs** to an
|
| 6 |
+
anonymous visitor: `api-live/user/room` comes back with `status=2` but empty
|
| 7 |
+
`streamData`/`hevcStreamData`, and `webcast/room/info/` answers with a
|
| 8 |
+
`prompts`/error payload instead of `stream_url`. The room is therefore
|
| 9 |
+
"unobservable" and can never be recorded or relayed.
|
| 10 |
+
|
| 11 |
+
TikTok's own web client unlocks those rooms by sending a logged-in session.
|
| 12 |
+
The cookie set that the reference implementations use is:
|
| 13 |
+
|
| 14 |
+
sessionid, sessionid_ss, sid_tt (+ tt-target-idc for the account's DC)
|
| 15 |
+
and the query flag user_is_login=true
|
| 16 |
+
|
| 17 |
+
This module builds exactly that, and nothing else changes: same endpoints,
|
| 18 |
+
same source ranking, same original-quality download. Sending the cookie to the
|
| 19 |
+
signed CDN pull URL was verified harmless (identical FLV bytes with and
|
| 20 |
+
without it), so one code path covers both API calls and video download.
|
| 21 |
+
|
| 22 |
+
Secrets handling
|
| 23 |
+
----------------
|
| 24 |
+
Tokens are never logged, never returned by any API response, and never written
|
| 25 |
+
into the recordings directory. Every outward-facing string is masked.
|
| 26 |
+
"""
|
| 27 |
+
import json, os, re, threading
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
# Account data-centre cookie; only used when a session id is present.
|
| 31 |
+
DEFAULT_TT_TARGET_IDC = 'useast1a'
|
| 32 |
+
# sessionid / sessionid_ss / sid_tt / tt-target-idc
|
| 33 |
+
SESSION_COOKIE_NAMES = ('sessionid', 'sessionid_ss', 'sid_tt')
|
| 34 |
+
AUTH_CHECK_URL = 'https://www.tiktok.com/passport/web/account/info/'
|
| 35 |
+
|
| 36 |
+
_lock = threading.RLock()
|
| 37 |
+
_store_path = None
|
| 38 |
+
_store_cache = None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# --------------------------------------------------------------------------- #
|
| 42 |
+
# storage
|
| 43 |
+
# --------------------------------------------------------------------------- #
|
| 44 |
+
def configure(path):
|
| 45 |
+
"""Point the optional on-volume token store at `<ROOT>/tiktok-tokens.json`."""
|
| 46 |
+
global _store_path, _store_cache
|
| 47 |
+
with _lock:
|
| 48 |
+
_store_path = Path(path) if path else None
|
| 49 |
+
_store_cache = None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _norm(user):
|
| 53 |
+
"""Env-var-safe account key: TIKTOK_SESSIONID_MY.NAME -> MY_NAME."""
|
| 54 |
+
return re.sub(r'[^A-Z0-9]', '_', str(user or '').strip().upper())
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _read_store():
|
| 58 |
+
global _store_cache
|
| 59 |
+
if _store_cache is not None:
|
| 60 |
+
return _store_cache
|
| 61 |
+
data = {}
|
| 62 |
+
if _store_path:
|
| 63 |
+
try:
|
| 64 |
+
loaded = json.loads(_store_path.read_text(encoding='utf-8'))
|
| 65 |
+
if isinstance(loaded, dict):
|
| 66 |
+
data = {str(k): v for k, v in loaded.items() if isinstance(v, dict)}
|
| 67 |
+
except (FileNotFoundError, ValueError, OSError):
|
| 68 |
+
data = {}
|
| 69 |
+
_store_cache = data
|
| 70 |
+
return data
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _write_store(data):
|
| 74 |
+
global _store_cache
|
| 75 |
+
if not _store_path:
|
| 76 |
+
raise RuntimeError('مخزن التوكنات غير مهيأ؛ استخدم متغيرات Secrets بدلًا منه')
|
| 77 |
+
tmp = _store_path.with_suffix('.tmp')
|
| 78 |
+
tmp.write_text(json.dumps(data, ensure_ascii=False), encoding='utf-8')
|
| 79 |
+
try:
|
| 80 |
+
os.chmod(tmp, 0o600)
|
| 81 |
+
except OSError:
|
| 82 |
+
pass
|
| 83 |
+
tmp.replace(_store_path)
|
| 84 |
+
try:
|
| 85 |
+
os.chmod(_store_path, 0o600)
|
| 86 |
+
except OSError:
|
| 87 |
+
pass
|
| 88 |
+
_store_cache = data
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _env(name):
|
| 92 |
+
return (os.getenv(name) or '').strip()
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# --------------------------------------------------------------------------- #
|
| 96 |
+
# parsing
|
| 97 |
+
# --------------------------------------------------------------------------- #
|
| 98 |
+
def parse_token(raw):
|
| 99 |
+
"""Accept whatever the user can realistically copy, return (sessionid, extra_cookies).
|
| 100 |
+
|
| 101 |
+
Supported inputs:
|
| 102 |
+
* bare session id 1a2b3c...
|
| 103 |
+
* `sessionid=1a2b3c...`
|
| 104 |
+
* full Cookie header `sessionid=x; sid_tt=x; ttwid=y; tt-target-idc=useast1a`
|
| 105 |
+
* Netscape cookies.txt line `.tiktok.com\tTRUE\t/\tTRUE\t0\tsessionid\tx`
|
| 106 |
+
* several of the above separated by newlines / commas / semicolons
|
| 107 |
+
Unknown cookie names are preserved (e.g. ttwid, msToken) and sent as-is.
|
| 108 |
+
"""
|
| 109 |
+
text = str(raw or '').strip()
|
| 110 |
+
if not text:
|
| 111 |
+
return '', {}
|
| 112 |
+
text = text.replace('\r', '\n')
|
| 113 |
+
pairs = {}
|
| 114 |
+
sessionid = ''
|
| 115 |
+
for line in text.split('\n'):
|
| 116 |
+
line = line.strip()
|
| 117 |
+
if not line or line.startswith('#'):
|
| 118 |
+
continue
|
| 119 |
+
if '\t' in line and line.count('\t') >= 6: # Netscape cookies.txt
|
| 120 |
+
parts = line.split('\t')
|
| 121 |
+
pairs[parts[5].strip()] = parts[6].strip()
|
| 122 |
+
continue
|
| 123 |
+
# a Cookie header may use ';' as separator, a token list may use ','
|
| 124 |
+
chunks = re.split(r'[;,]', line) if ('=' in line) else [line]
|
| 125 |
+
for chunk in chunks:
|
| 126 |
+
chunk = chunk.strip()
|
| 127 |
+
if not chunk:
|
| 128 |
+
continue
|
| 129 |
+
if '=' in chunk:
|
| 130 |
+
k, _, v = chunk.partition('=')
|
| 131 |
+
pairs[k.strip()] = v.strip()
|
| 132 |
+
elif not sessionid:
|
| 133 |
+
sessionid = chunk
|
| 134 |
+
for name in SESSION_COOKIE_NAMES:
|
| 135 |
+
if pairs.get(name) and not sessionid:
|
| 136 |
+
sessionid = pairs[name]
|
| 137 |
+
if sessionid:
|
| 138 |
+
for name in SESSION_COOKIE_NAMES:
|
| 139 |
+
pairs.setdefault(name, sessionid)
|
| 140 |
+
idc = pairs.get('tt-target-idc') or pairs.get('tt_target_idc') or _env('TIKTOK_TT_TARGET_IDC') or DEFAULT_TT_TARGET_IDC
|
| 141 |
+
if sessionid:
|
| 142 |
+
pairs['tt-target-idc'] = idc.strip()
|
| 143 |
+
return sessionid.strip(), {k: v for k, v in pairs.items() if v}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def session_expiry(pairs):
|
| 147 |
+
"""Decode `sid_guard` if present: "<sid>|<issued>|<ttl_seconds>|<HTTP date>".
|
| 148 |
+
|
| 149 |
+
TikTok issues short-lived web sessions (the sample we were given had
|
| 150 |
+
ttl=21600, i.e. 6 hours), so an expired token is the single most common
|
| 151 |
+
reason a pasted token "doesn't work". Reporting the deadline up front saves
|
| 152 |
+
a debugging round trip. Returns None when sid_guard is absent.
|
| 153 |
+
"""
|
| 154 |
+
raw = (pairs or {}).get('sid_guard')
|
| 155 |
+
if not raw:
|
| 156 |
+
return None
|
| 157 |
+
try:
|
| 158 |
+
from urllib.parse import unquote
|
| 159 |
+
parts = unquote(str(raw)).split('|')
|
| 160 |
+
if len(parts) < 3:
|
| 161 |
+
return None
|
| 162 |
+
issued = int(parts[1])
|
| 163 |
+
ttl = int(parts[2])
|
| 164 |
+
except (ValueError, TypeError):
|
| 165 |
+
return None
|
| 166 |
+
import time as _time
|
| 167 |
+
expires = issued + ttl
|
| 168 |
+
return {'issued_at': issued, 'ttl_seconds': ttl, 'expires_at': expires,
|
| 169 |
+
'expired': _time.time() > expires,
|
| 170 |
+
'seconds_left': max(0, expires - int(_time.time())),
|
| 171 |
+
'expires_text': _time.strftime('%Y-%m-%d %H:%M UTC', _time.gmtime(expires))}
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def mask(value):
|
| 175 |
+
"""Safe display form. Never reveals enough of the token to be usable."""
|
| 176 |
+
s = str(value or '')
|
| 177 |
+
if not s:
|
| 178 |
+
return ''
|
| 179 |
+
if len(s) <= 12:
|
| 180 |
+
return '*' * len(s)
|
| 181 |
+
return '%s…%s (%d حرفًا)' % (s[:4], s[-4:], len(s))
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _looks_like_sessionid(value):
|
| 185 |
+
"""sessionid is a long opaque token; reject obvious copy/paste mistakes early."""
|
| 186 |
+
s = str(value or '').strip()
|
| 187 |
+
if len(s) < 20:
|
| 188 |
+
return False, 'قصير جدًا (%d) — التوكن الصحيح عادةً أكثر من 100 حرف' % len(s)
|
| 189 |
+
if len(s) > 4096:
|
| 190 |
+
return False, 'طويل بشكل غير طبيعي (%d)' % len(s)
|
| 191 |
+
if re.search(r'[\s"\']', s):
|
| 192 |
+
return False, 'يحتوي مسافة أو علامة اقتباس — انسخ القيمة وحدها'
|
| 193 |
+
if not re.fullmatch(r'[A-Za-z0-9%_\-:.+/=]+', s):
|
| 194 |
+
return False, 'يحتوي رموزًا غير متوقعة'
|
| 195 |
+
return True, ''
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
# --------------------------------------------------------------------------- #
|
| 199 |
+
# lookup
|
| 200 |
+
# --------------------------------------------------------------------------- #
|
| 201 |
+
def session_for(user=None):
|
| 202 |
+
"""Resolve the effective session for an account.
|
| 203 |
+
|
| 204 |
+
Precedence (highest first). Secrets always win over the UI store, and a
|
| 205 |
+
per-account value always wins over a global one:
|
| 206 |
+
|
| 207 |
+
1. TIKTOK_SESSIONID_<USER> per-account session id (Secret)
|
| 208 |
+
2. TIKTOK_COOKIES_<USER> per-account cookie header (Secret)
|
| 209 |
+
3. TIKTOK_TOKENS[user] per-account map entry (Secret)
|
| 210 |
+
4. TIKTOK_SESSIONID global, every account (Secret)
|
| 211 |
+
5. TIKTOK_COOKIES global cookie header (Secret)
|
| 212 |
+
6. store[user] per-account, saved from the UI
|
| 213 |
+
7. store['_default'] global, saved from the UI
|
| 214 |
+
|
| 215 |
+
Rationale for 1-5 beating 6-7: a token pasted into the UI earlier must not
|
| 216 |
+
silently override a freshly rotated Secret and keep failing in a way that
|
| 217 |
+
looks like TikTok's fault.
|
| 218 |
+
"""
|
| 219 |
+
key = _norm(user) if user else ''
|
| 220 |
+
if key:
|
| 221 |
+
raw = _env('TIKTOK_SESSIONID_' + key)
|
| 222 |
+
if raw:
|
| 223 |
+
sid, pairs = parse_token(raw)
|
| 224 |
+
return _pack(sid, pairs, 'TIKTOK_SESSIONID_' + key, user)
|
| 225 |
+
raw = _env('TIKTOK_COOKIES_' + key)
|
| 226 |
+
if raw:
|
| 227 |
+
sid, pairs = parse_token(raw)
|
| 228 |
+
return _pack(sid, pairs, 'TIKTOK_COOKIES_' + key, user)
|
| 229 |
+
mapping = _env('TIKTOK_TOKENS')
|
| 230 |
+
if mapping:
|
| 231 |
+
for chunk in re.split(r'[;\n]', mapping):
|
| 232 |
+
if not chunk.strip():
|
| 233 |
+
continue
|
| 234 |
+
name, _, value = chunk.partition('=')
|
| 235 |
+
if not value:
|
| 236 |
+
name, _, value = chunk.partition(':')
|
| 237 |
+
if _norm(name.strip()) == key and value.strip():
|
| 238 |
+
sid, pairs = parse_token(value)
|
| 239 |
+
return _pack(sid, pairs, 'TIKTOK_TOKENS', user)
|
| 240 |
+
raw = _env('TIKTOK_SESSIONID')
|
| 241 |
+
if raw:
|
| 242 |
+
sid, pairs = parse_token(raw)
|
| 243 |
+
return _pack(sid, pairs, 'TIKTOK_SESSIONID', user)
|
| 244 |
+
raw = _env('TIKTOK_COOKIES')
|
| 245 |
+
if raw:
|
| 246 |
+
sid, pairs = parse_token(raw)
|
| 247 |
+
return _pack(sid, pairs, 'TIKTOK_COOKIES', user)
|
| 248 |
+
store = _read_store()
|
| 249 |
+
entry = None
|
| 250 |
+
source_label = 'مخزن التطبيق'
|
| 251 |
+
if user:
|
| 252 |
+
entry = store.get(str(user).lower()) or store.get(key)
|
| 253 |
+
if not (isinstance(entry, dict) and (entry.get('cookie') or entry.get('sessionid'))):
|
| 254 |
+
entry = store.get('_default')
|
| 255 |
+
source_label = 'مخزن التطبيق (اف��راضي)'
|
| 256 |
+
if isinstance(entry, dict) and (entry.get('cookie') or entry.get('sessionid')):
|
| 257 |
+
sid, pairs = parse_token(entry.get('cookie') or entry.get('sessionid'))
|
| 258 |
+
return _pack(sid, pairs, source_label, user)
|
| 259 |
+
return _pack('', {}, None, user)
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def _pack(sessionid, pairs, source, user):
|
| 263 |
+
cookie = '; '.join('%s=%s' % (k, v) for k, v in pairs.items()) if pairs else ''
|
| 264 |
+
return {'user': str(user or '').lower(), 'sessionid': sessionid, 'cookie': cookie,
|
| 265 |
+
'authenticated': bool(sessionid), 'source': source,
|
| 266 |
+
'tt_target_idc': pairs.get('tt-target-idc', ''), 'masked': mask(sessionid),
|
| 267 |
+
'expiry': session_expiry(pairs)}
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def headers(user=None, extra=None):
|
| 271 |
+
"""Headers for any tiktok.com / webcast.tiktok.com / CDN pull request."""
|
| 272 |
+
result = {'Referer': 'https://www.tiktok.com/'}
|
| 273 |
+
sess = session_for(user)
|
| 274 |
+
if sess['cookie']:
|
| 275 |
+
result['Cookie'] = sess['cookie']
|
| 276 |
+
if extra:
|
| 277 |
+
result.update(extra)
|
| 278 |
+
return result
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def extra_params(user=None):
|
| 282 |
+
"""TikTok gates some payloads on this flag; harmless when anonymous."""
|
| 283 |
+
return {'user_is_login': 'true'} if session_for(user)['authenticated'] else {}
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def describe(users=()):
|
| 287 |
+
"""Non-secret summary for the UI/state endpoint."""
|
| 288 |
+
users = list(users or ())
|
| 289 |
+
per_account = {}
|
| 290 |
+
for u in users:
|
| 291 |
+
s = session_for(u)
|
| 292 |
+
per_account[u] = {'authenticated': s['authenticated'], 'source': s['source'],
|
| 293 |
+
'masked': s['masked'], 'tt_target_idc': s['tt_target_idc'],
|
| 294 |
+
'expiry': s.get('expiry')}
|
| 295 |
+
glob = session_for(None)
|
| 296 |
+
known_vars = sorted({n for n in os.environ if n.startswith('TIKTOK_')})
|
| 297 |
+
return {'default_authenticated': glob['authenticated'], 'default_source': glob['source'],
|
| 298 |
+
'default_masked': glob['masked'], 'default_expiry': glob.get('expiry'),
|
| 299 |
+
'accounts': per_account,
|
| 300 |
+
'accounts_with_token': [u for u, v in per_account.items() if v['authenticated']],
|
| 301 |
+
'env_variables_present': known_vars,
|
| 302 |
+
'store_configured': bool(_store_path),
|
| 303 |
+
'store_entries': sorted(k for k in _read_store() if k != '_default')}
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
# --------------------------------------------------------------------------- #
|
| 307 |
+
# save / delete (on-volume store)
|
| 308 |
+
# --------------------------------------------------------------------------- #
|
| 309 |
+
def save(user, token):
|
| 310 |
+
user = ('_default' if not user else str(user).strip().lower())
|
| 311 |
+
sessionid, pairs = parse_token(token)
|
| 312 |
+
if not sessionid:
|
| 313 |
+
raise ValueError('لم يُعثر على sessionid صالح فيما لصقته')
|
| 314 |
+
ok, why = _looks_like_sessionid(sessionid)
|
| 315 |
+
if not ok:
|
| 316 |
+
raise ValueError('صيغة التوكن غير مقبولة: ' + why)
|
| 317 |
+
with _lock:
|
| 318 |
+
data = dict(_read_store())
|
| 319 |
+
data[user] = {'cookie': '; '.join('%s=%s' % (k, v) for k, v in pairs.items()),
|
| 320 |
+
'sessionid': sessionid, 'tt_target_idc': pairs.get('tt-target-idc', ''),
|
| 321 |
+
'sid_guard': pairs.get('sid_guard', '')}
|
| 322 |
+
_write_store(data)
|
| 323 |
+
return {'user': user, 'masked': mask(sessionid), 'tt_target_idc': pairs.get('tt-target-idc', '')}
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def delete(user):
|
| 327 |
+
user = ('_default' if not user else str(user).strip().lower())
|
| 328 |
+
with _lock:
|
| 329 |
+
data = dict(_read_store())
|
| 330 |
+
if user not in data:
|
| 331 |
+
return False
|
| 332 |
+
del data[user]
|
| 333 |
+
_write_store(data)
|
| 334 |
+
return True
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
# --------------------------------------------------------------------------- #
|
| 338 |
+
# validation
|
| 339 |
+
# --------------------------------------------------------------------------- #
|
| 340 |
+
def validate(token, user=None, timeout=25):
|
| 341 |
+
"""Check a token against TikTok itself. Returns a plain-language report.
|
| 342 |
+
|
| 343 |
+
Never raises for a *bad* token — a bad token is a normal, reportable result.
|
| 344 |
+
Raises only on programmer/transport errors.
|
| 345 |
+
"""
|
| 346 |
+
from curl_cffi import requests
|
| 347 |
+
sessionid, pairs = parse_token(token)
|
| 348 |
+
report = {'ok': False, 'stage': 'format', 'sessionid_masked': mask(sessionid),
|
| 349 |
+
'tt_target_idc': pairs.get('tt-target-idc', ''), 'account': None, 'checks': [],
|
| 350 |
+
'expiry': session_expiry(pairs)}
|
| 351 |
+
if report['expiry'] and report['expiry']['expired']:
|
| 352 |
+
report['checks'].append('انتهت صلاحية هذه الجلسة يوم %s (عمرها %d ساعة)'
|
| 353 |
+
% (report['expiry']['expires_text'], max(1, report['expiry']['ttl_seconds'] // 3600)))
|
| 354 |
+
if not sessionid:
|
| 355 |
+
report['message'] = 'لم يُعثر على sessionid. الصق قيمة الكوكي sessionid نفسها، أو كامل سطر Cookie.'
|
| 356 |
+
return report
|
| 357 |
+
ok, why = _looks_like_sessionid(sessionid)
|
| 358 |
+
if not ok:
|
| 359 |
+
report['message'] = 'صيغة غير مقبولة: ' + why
|
| 360 |
+
return report
|
| 361 |
+
report['checks'].append('الصيغة مقبولة')
|
| 362 |
+
cookie = '; '.join('%s=%s' % (k, v) for k, v in pairs.items())
|
| 363 |
+
report['stage'] = 'auth'
|
| 364 |
+
try:
|
| 365 |
+
r = requests.get(AUTH_CHECK_URL, impersonate='chrome', timeout=timeout,
|
| 366 |
+
headers={'Referer': 'https://www.tiktok.com/', 'Cookie': cookie})
|
| 367 |
+
payload = r.json()
|
| 368 |
+
except Exception:
|
| 369 |
+
report['message'] = 'تعذر الوصول إلى تيك توك للتحقق (شبكة أو حظر). أعد المحاولة.'
|
| 370 |
+
return report
|
| 371 |
+
data = payload.get('data') if isinstance(payload.get('data'), dict) else {}
|
| 372 |
+
if data.get('error_code') or payload.get('message') == 'error':
|
| 373 |
+
name = str(data.get('name') or '')
|
| 374 |
+
if name == 'session_expired' or data.get('error_code') == 13:
|
| 375 |
+
exp = report.get('expiry')
|
| 376 |
+
detail = (' (انتهت فعليًا يوم %s)' % exp['expires_text']) if exp and exp['expired'] else ''
|
| 377 |
+
report['message'] = ('تيك توك رفض التوكن: الجلسة منتهية أو غير صالحة%s. '
|
| 378 |
+
'سجّل الدخول في المتصفح وانسخ sessionid من جديد — '
|
| 379 |
+
'جلسات تيك توك قصيرة العمر وتنتهي خلال ساعات.' % detail)
|
| 380 |
+
else:
|
| 381 |
+
report['message'] = 'تيك توك رفض التوكن (%s / %s).' % (data.get('error_code'), name or 'unknown')
|
| 382 |
+
return report
|
| 383 |
+
report['ok'] = True
|
| 384 |
+
report['stage'] = 'accepted'
|
| 385 |
+
ident = {}
|
| 386 |
+
for field in ('username', 'unique_id', 'nickname', 'screen_name', 'uid', 'user_id', 'sec_uid',
|
| 387 |
+
'email', 'mobile', 'has_password', 'is_18_plus', 'birthday', 'verified'):
|
| 388 |
+
if data.get(field) not in (None, ''):
|
| 389 |
+
ident[field] = str(data[field])[:60]
|
| 390 |
+
report['account'] = ident
|
| 391 |
+
report['checks'].append('تيك توك قبل التوكن وأعاده كجلسة مسجّلة دخول')
|
| 392 |
+
who = ident.get('username') or ident.get('unique_id') or ident.get('nickname') or 'حساب مرتبط'
|
| 393 |
+
report['message'] = 'التوكن صالح ومقبول. مرتبط بـ: %s' % who
|
| 394 |
+
if ident.get('birthday') or ident.get('is_18_plus'):
|
| 395 |
+
report['checks'].append('بيانات العمر ظاهرة في الجلسة (مهم لفتح البثوث المقيّدة 18+)')
|
| 396 |
+
if user:
|
| 397 |
+
report['checks'].append('سيُستخدم هذا التوكن للحساب @%s' % str(user).lower())
|
| 398 |
+
return report
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def unlock_check(user, timeout=25):
|
| 402 |
+
"""Does the configured session actually expose stream URLs for `user`?
|
| 403 |
+
|
| 404 |
+
Compares anonymous vs authenticated lookups, so it reports the *effect*
|
| 405 |
+
rather than just token validity. Read-only: creates nothing.
|
| 406 |
+
"""
|
| 407 |
+
from curl_cffi import requests
|
| 408 |
+
user = str(user).strip().lower().lstrip('@')
|
| 409 |
+
if not re.fullmatch(r'[A-Za-z0-9_.]{1,32}', user):
|
| 410 |
+
raise ValueError('اسم حساب غير صالح')
|
| 411 |
+
sess = session_for(user)
|
| 412 |
+
|
| 413 |
+
def probe(with_token):
|
| 414 |
+
hdrs = {'Referer': 'https://www.tiktok.com/'}
|
| 415 |
+
if with_token and sess['cookie']:
|
| 416 |
+
hdrs['Cookie'] = sess['cookie']
|
| 417 |
+
params = {'aid': 1988, 'uniqueId': user, 'sourceType': 54, 'staleTime': 0}
|
| 418 |
+
if with_token and sess['authenticated']:
|
| 419 |
+
params['user_is_login'] = 'true'
|
| 420 |
+
r = requests.get('https://www.tiktok.com/api-live/user/room', params=params,
|
| 421 |
+
impersonate='chrome', headers=hdrs, timeout=timeout)
|
| 422 |
+
j = r.json()
|
| 423 |
+
d = (j.get('data') or {})
|
| 424 |
+
live = d.get('liveRoom') or {}
|
| 425 |
+
blocks = [live[k] for k in ('streamData', 'hevcStreamData') if isinstance(live.get(k), dict)]
|
| 426 |
+
urls = 0
|
| 427 |
+
for b in blocks:
|
| 428 |
+
p = (b.get('pull_data') or {}).get('stream_data')
|
| 429 |
+
try:
|
| 430 |
+
s = json.loads(p) if isinstance(p, str) else (p or {})
|
| 431 |
+
except ValueError:
|
| 432 |
+
s = {}
|
| 433 |
+
for _q, item in (s.get('data') or {}).items():
|
| 434 |
+
if ((item.get('main') or {}).get('flv')):
|
| 435 |
+
urls += 1
|
| 436 |
+
return {'status': live.get('status'), 'urls': urls,
|
| 437 |
+
'not_found': j.get('message') == 'user_not_found'}
|
| 438 |
+
|
| 439 |
+
anon = probe(False)
|
| 440 |
+
if anon['not_found']:
|
| 441 |
+
return {'user': user, 'result': 'user_not_found', 'token_configured': sess['authenticated'],
|
| 442 |
+
'message': 'تيك توك يقول أن الحساب غير موجود أو لم يبث مباشرًا قط. التوكن لا يحل هذه الحالة.'}
|
| 443 |
+
if not sess['authenticated']:
|
| 444 |
+
state = 'live' if anon['status'] in (2, '2') else 'offline'
|
| 445 |
+
if state == 'live' and anon['urls'] == 0:
|
| 446 |
+
return {'user': user, 'result': 'needs_token', 'token_configured': False, 'anonymous': anon,
|
| 447 |
+
'message': 'الحساب مباشر لكن تيك توك لا يعطي روابط فيديو بدون تسجيل دخول — هذه حالة تقييد عمري. أضف توكن.'}
|
| 448 |
+
return {'user': user, 'result': state, 'token_configured': False, 'anonymous': anon,
|
| 449 |
+
'message': 'بدون توكن: %s وروابط الفيديو = %d.' % (state, anon['urls'])}
|
| 450 |
+
auth = probe(True)
|
| 451 |
+
gained = auth['urls'] - anon['urls']
|
| 452 |
+
if gained > 0:
|
| 453 |
+
return {'user': user, 'result': 'unlocked', 'token_configured': True,
|
| 454 |
+
'anonymous': anon, 'authenticated': auth,
|
| 455 |
+
'message': 'التوكن يعمل ✔ روابط الفيديو: %d بدون توكن ← %d مع التوكن.' % (anon['urls'], auth['urls'])}
|
| 456 |
+
if anon['urls'] > 0 and auth['urls'] > 0:
|
| 457 |
+
return {'user': user, 'result': 'already_open', 'token_configured': True,
|
| 458 |
+
'anonymous': anon, 'authenticated': auth,
|
| 459 |
+
'message': 'الحساب مفتوح أصلًا بدون توكن (%d رابط). التوكن لا يضر لكن ليس سبب المشكلة.' % anon['urls']}
|
| 460 |
+
return {'user': user, 'result': 'not_unlocked', 'token_configured': True,
|
| 461 |
+
'anonymous': anon, 'authenticated': auth,
|
| 462 |
+
'message': ('التوكن مقبول لكن لم يفتح روابط الفيديو لهذا الحساب الآن. '
|
| 463 |
+
'الأسباب المحتملة: الحساب غير مباشر حاليًا، أو عمر الحساب المرتبط بالتوكن أقل من 18، '
|
| 464 |
+
'أو الحساب محظور عنك. جرّب والفحص أثناء بث حي.')}
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
def classify_restriction(payload):
|
| 468 |
+
"""True when a webcast payload is the age-restriction / prompts error shape."""
|
| 469 |
+
if not isinstance(payload, dict):
|
| 470 |
+
return False
|
| 471 |
+
data = payload.get('data')
|
| 472 |
+
if not isinstance(data, dict):
|
| 473 |
+
return False
|
| 474 |
+
if 'prompts' in data and not data.get('stream_url'):
|
| 475 |
+
return True
|
| 476 |
+
return len(data) <= 1 and payload.get('status_code') not in (0, None)
|
verify.sh
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 "✅ كل الفحوصات نجحت — الملفات مُصلَحة وجاهزة."
|
verify_tokens.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Token checks used by verify.sh — kept in its own file to avoid nested heredocs."""
|
| 2 |
+
import importlib.util, json, os, sys, tempfile
|
| 3 |
+
|
| 4 |
+
sp = importlib.util.spec_from_file_location('tiktok_auth', 'tiktok_auth.py')
|
| 5 |
+
ta = importlib.util.module_from_spec(sp)
|
| 6 |
+
sys.modules['tiktok_auth'] = ta
|
| 7 |
+
sp.loader.exec_module(ta)
|
| 8 |
+
|
| 9 |
+
tmp = tempfile.mkdtemp()
|
| 10 |
+
ta.configure(os.path.join(tmp, 'tiktok-tokens.json'))
|
| 11 |
+
FAKE = 'z' * 160
|
| 12 |
+
|
| 13 |
+
# --- 1. every input shape a user can realistically paste ------------------- #
|
| 14 |
+
shapes = {
|
| 15 |
+
'قيمة sessionid وحدها': FAKE,
|
| 16 |
+
'sessionid=...': 'sessionid=' + FAKE,
|
| 17 |
+
'سطر Cookie كامل': 'sessionid=%s; sid_tt=%s; ttwid=ABC; tt-target-idc=eu-ttp2' % (FAKE, FAKE),
|
| 18 |
+
'سطر cookies.txt': '.tiktok.com\tTRUE\t/\tTRUE\t1799999999\tsessionid\t' + FAKE,
|
| 19 |
+
'عدة أسطر': 'sessionid=' + FAKE + '\nttwid=ABC',
|
| 20 |
+
}
|
| 21 |
+
for name, raw in shapes.items():
|
| 22 |
+
sid, pairs = ta.parse_token(raw)
|
| 23 |
+
assert sid == FAKE, (name, sid[:20])
|
| 24 |
+
for required in ('sessionid', 'sessionid_ss', 'sid_tt'):
|
| 25 |
+
assert required in pairs, (name, sorted(pairs))
|
| 26 |
+
print(' OK — يقبل: %-22s (كوكيز: %d)' % (name, len(pairs)))
|
| 27 |
+
|
| 28 |
+
sid, pairs = ta.parse_token(shapes['سطر Cookie كامل'])
|
| 29 |
+
assert pairs['tt-target-idc'] == 'eu-ttp2', pairs
|
| 30 |
+
print(' OK — يستخرج tt-target-idc من سطر الكوكي (eu-ttp2)')
|
| 31 |
+
|
| 32 |
+
# --- 2. precedence (most specific wins) ----------------------------------- #
|
| 33 |
+
G = 'g' * 160
|
| 34 |
+
ta._store_cache = None
|
| 35 |
+
ta.save('sara', 's' * 160) # lowest priority source
|
| 36 |
+
os.environ['TIKTOK_SESSIONID'] = G # global fallback
|
| 37 |
+
os.environ['TIKTOK_TOKENS'] = 'sara=' + 't' * 160
|
| 38 |
+
os.environ['TIKTOK_SESSIONID_SARA'] = 'a' * 160 # explicit per-account
|
| 39 |
+
assert ta.session_for('sara')['source'] == 'TIKTOK_SESSIONID_SARA', ta.session_for('sara')
|
| 40 |
+
assert ta.session_for('other')['source'] == 'TIKTOK_SESSIONID' # global covers other accounts
|
| 41 |
+
print(' OK — TIKTOK_SESSIONID_<USER> يتقدم على كل شيء، والعام يغطي باقي الحسابات')
|
| 42 |
+
|
| 43 |
+
os.environ.pop('TIKTOK_SESSIONID_SARA')
|
| 44 |
+
assert ta.session_for('sara')['source'] == 'TIKTOK_TOKENS', ta.session_for('sara')
|
| 45 |
+
print(' OK — خريطة TIKTOK_TOKENS (تخصيص للحساب) تتقدم على التوكن العام')
|
| 46 |
+
|
| 47 |
+
os.environ.pop('TIKTOK_TOKENS')
|
| 48 |
+
assert ta.session_for('sara')['source'] == 'TIKTOK_SESSIONID'
|
| 49 |
+
print(' OK — التوكن العام يعمل عندما لا يوجد تخصيص للحساب')
|
| 50 |
+
|
| 51 |
+
os.environ.pop('TIKTOK_SESSIONID')
|
| 52 |
+
ta._store_cache = None
|
| 53 |
+
assert ta.session_for('sara')['source'] == 'مخزن التطبيق'
|
| 54 |
+
assert ta.session_for(None)['source'] is None
|
| 55 |
+
print(' OK — مخزن الواجهة هو آخر احتياطي')
|
| 56 |
+
|
| 57 |
+
# --- 3. the store file must not be world-readable -------------------------- #
|
| 58 |
+
mode = os.stat(os.path.join(tmp, 'tiktok-tokens.json')).st_mode & 0o777
|
| 59 |
+
assert mode == 0o600, oct(mode)
|
| 60 |
+
print(' OK — ملف التوكنات محفوظ بصلاحيات 600')
|
| 61 |
+
|
| 62 |
+
# --- 4. nothing secret may leave through the API surface ------------------- #
|
| 63 |
+
os.environ['TIKTOK_SESSIONID'] = 'g' * 160
|
| 64 |
+
ta._store_cache = None
|
| 65 |
+
blob = json.dumps(ta.describe(['ali.karim', 'sara']), ensure_ascii=False)
|
| 66 |
+
for secret in ('g' * 160, 'a' * 160, 's' * 160, 't' * 160, FAKE):
|
| 67 |
+
assert secret not in blob, 'TOKEN LEAK in describe()'
|
| 68 |
+
assert 'gggg' in blob and 'ggggg' not in blob.replace('gggg…', '')
|
| 69 |
+
hdrs = ta.headers('sara')
|
| 70 |
+
assert set(hdrs) == {'Referer', 'Cookie'}
|
| 71 |
+
assert [c.split('=')[0] for c in hdrs['Cookie'].split('; ')] == ['sessionid', 'sessionid_ss', 'sid_tt', 'tt-target-idc']
|
| 72 |
+
assert ta.extra_params('sara') == {'user_is_login': 'true'}
|
| 73 |
+
print(' OK — describe()/headers() لا يسرّبان التوكن (مقنّع فقط) وأسماء الكوكيز صحيحة')
|
| 74 |
+
|
| 75 |
+
os.environ.pop('TIKTOK_SESSIONID')
|
| 76 |
+
ta._store_cache = None
|
| 77 |
+
assert ta.headers('sara') == {'Referer': 'https://www.tiktok.com/'} or 'Cookie' in ta.headers('sara')
|
| 78 |
+
ta.delete('sara'); ta.delete('_default')
|
| 79 |
+
ta._store_cache = None
|
| 80 |
+
assert ta.headers('nobody') == {'Referer': 'https://www.tiktok.com/'}, 'anonymous must stay byte-identical to before'
|
| 81 |
+
assert ta.extra_params('nobody') == {}
|
| 82 |
+
print(' OK — بدون توكن: الترويسات مطابقة تمامًا للسلوك السابق (Referer فقط)')
|
| 83 |
+
|
| 84 |
+
# --- 5. age-restriction classification ------------------------------------ #
|
| 85 |
+
cases = [
|
| 86 |
+
({'status_code': 10012, 'data': {'message': 'age restricted', 'prompts': 'x'}}, True),
|
| 87 |
+
({'status_code': 10012, 'data': {}}, True),
|
| 88 |
+
({'status_code': 0, 'data': {'status': 2, 'stream_url': {'flv_pull_url': {}}}}, False),
|
| 89 |
+
({'data': None}, False),
|
| 90 |
+
('not a dict', False),
|
| 91 |
+
]
|
| 92 |
+
for payload, expected in cases:
|
| 93 |
+
assert ta.classify_restriction(payload) is expected, payload
|
| 94 |
+
print(' OK — يميّز استجابة التقييد العمري عن الغرفة الطبيعية')
|
| 95 |
+
|
| 96 |
+
# --- 6. bad formats rejected before any network call ---------------------- #
|
| 97 |
+
for bad in ('', '12345', 'abc def ghi jkl mno pqr stu vwx yz', 'x' * 5000):
|
| 98 |
+
sid, _ = ta.parse_token(bad)
|
| 99 |
+
if sid:
|
| 100 |
+
ok, _why = ta._looks_like_sessionid(sid)
|
| 101 |
+
assert not ok, bad
|
| 102 |
+
print(' OK — يرفض الصيغ الخاطئة قبل أي طلب شبكة')
|
| 103 |
+
|
| 104 |
+
# --- 6b. session expiry decoded from sid_guard ---------------------------- #
|
| 105 |
+
# Real-world sample: TikTok issued a web session with a 6 hour lifetime.
|
| 106 |
+
SG = ('x' * 32) + '%7C1789387145%7C21600%7CMon%2C+14-Sep-2026+17%3A59%3A05+GMT'
|
| 107 |
+
_sid, pairs = ta.parse_token('sessionid=' + 'x' * 32 + '; sid_guard=' + SG)
|
| 108 |
+
exp = ta.session_expiry(pairs)
|
| 109 |
+
assert exp and exp['ttl_seconds'] == 21600, exp
|
| 110 |
+
assert exp['expires_text'] == '2026-09-14 17:59 UTC', exp
|
| 111 |
+
assert exp['expired'] is True and exp['seconds_left'] == 0, exp
|
| 112 |
+
print(' OK — يفكّ sid_guard: عمر الجلسة 6 ساعات وانتهت %s' % exp['expires_text'])
|
| 113 |
+
|
| 114 |
+
import time as _t
|
| 115 |
+
_sg_live = 'y' * 32 + '%%7C%d%%7C7200%%7Cnow' % int(_t.time())
|
| 116 |
+
_sid, pairs = ta.parse_token('sessionid=' + 'y' * 32 + '; sid_guard=' + _sg_live)
|
| 117 |
+
exp = ta.session_expiry(pairs)
|
| 118 |
+
assert exp and exp['expired'] is False and 7100 < exp['seconds_left'] <= 7200, exp
|
| 119 |
+
print(' OK — جلسة حية تُبلَّغ كصالحه مع العد التنازلي (%d ثانية متبقية)' % exp['seconds_left'])
|
| 120 |
+
|
| 121 |
+
_sid, pairs = ta.parse_token('sessionid=' + 'v' * 160)
|
| 122 |
+
assert ta.session_expiry(pairs) is None
|
| 123 |
+
print(' OK — بدون sid_guard لا يخمّن موعد انتهاء (None)')
|
| 124 |
+
|
| 125 |
+
rep = ta.validate('sessionid=' + 'x' * 32 + '; sid_guard=' + SG)
|
| 126 |
+
assert rep['expiry'] and rep['expiry']['expired']
|
| 127 |
+
assert '2026-09-14 17:59 UTC' in rep['message'], rep['message']
|
| 128 |
+
print(' OK — رسالة الرفض تذكر موعد الانتهاء الفعلي')
|
| 129 |
+
|
| 130 |
+
# --- 7. live: TikTok must reject a fake token, and the app must say so ----- #
|
| 131 |
+
try:
|
| 132 |
+
r = ta.validate('q' * 160)
|
| 133 |
+
assert r['ok'] is False, r
|
| 134 |
+
assert r['stage'] in ('auth', 'format'), r
|
| 135 |
+
print(' OK — تيك توك رفض التوكن المزيّف والتطبيق بلّغ: %s' % r['message'][:60])
|
| 136 |
+
except AssertionError:
|
| 137 |
+
raise
|
| 138 |
+
except Exception as e:
|
| 139 |
+
print(' SKIP — تعذر الوصول إلى تيك توك (%s)' % type(e).__name__)
|
| 140 |
+
|
| 141 |
+
# --- 8. live: unlock_check must not crash on a public account ------------- #
|
| 142 |
+
try:
|
| 143 |
+
res = ta.unlock_check('aljazeera', timeout=20)
|
| 144 |
+
assert 'message' in res and 'result' in res
|
| 145 |
+
print(' OK — unlock_check على حساب عام: result=%s' % res['result'])
|
| 146 |
+
res = ta.unlock_check('thisaccountdoesnotexist99x', timeout=20)
|
| 147 |
+
assert res['result'] == 'user_not_found'
|
| 148 |
+
print(' OK — unlock_check يميّز الحساب غير الموجود')
|
| 149 |
+
except Exception as e:
|
| 150 |
+
print(' SKIP — تعذر الوصول إلى تيك توك (%s)' % type(e).__name__)
|
| 151 |
+
|
| 152 |
+
print(' كل فحوصات التوكن نجحت.')
|
youtube_api.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Google API transport and durable non-secret journal. No credentials in files/logs."""
|
| 2 |
+
import copy, json, os, re, ssl, threading, time, uuid
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from zoneinfo import ZoneInfo
|
| 5 |
+
from urllib.request import Request, urlopen
|
| 6 |
+
from urllib.error import HTTPError, URLError
|
| 7 |
+
from urllib.parse import urlencode
|
| 8 |
+
|
| 9 |
+
class APIError(RuntimeError):
|
| 10 |
+
def __init__(self, status, reason):
|
| 11 |
+
self.status=status
|
| 12 |
+
self.reason=re.sub(r'[^a-zA-Z0-9_.-]','',str(reason))[:100] or 'unknown'
|
| 13 |
+
super().__init__(f'YouTube API: {status or "network"} / {self.reason}')
|
| 14 |
+
|
| 15 |
+
# V8 defaults: name/title/hashtags are pulled from the TikTok account itself.
|
| 16 |
+
DEFAULT_SETTINGS={'name':'','privacy':'public','title':'{title}',
|
| 17 |
+
'description':'بث {name} المباشر\n👤 المذيع: {name} — @{username}\n🔗 https://www.tiktok.com/@{username}\n\n📅 {date} — ⏰ {time} بتوقيت بغداد\n{hashtags}'}
|
| 18 |
+
OLD_DEFAULT_TITLE='{name} | بث مباشر | {date} — {time}'
|
| 19 |
+
|
| 20 |
+
class Journal:
|
| 21 |
+
"""Schema 7: multiple concurrent events + one reusable ingestion stream per account."""
|
| 22 |
+
def __init__(self,path):
|
| 23 |
+
self.path=path;self.lock=threading.RLock()
|
| 24 |
+
try:self.path.parent.mkdir(parents=True,exist_ok=True)
|
| 25 |
+
except OSError:pass
|
| 26 |
+
try:self.data=json.loads(path.read_text(encoding='utf-8'))
|
| 27 |
+
except FileNotFoundError:
|
| 28 |
+
self.data={'schema':7,'instance':uuid.uuid4().hex,'enabled':False,'channel':None,
|
| 29 |
+
'streams':{},'events':{},'history':[],'budget':{},'settings':dict(DEFAULT_SETTINGS)}
|
| 30 |
+
self.change(lambda d:None)
|
| 31 |
+
except (ValueError,OSError) as e:raise RuntimeError('تعذر قراءة سجل أحداث يوتيوب؛ لا تحذفه. يلزم إصلاح التخزين لمنع التكرار') from None
|
| 32 |
+
if not isinstance(self.data,dict) or self.data.get('schema') not in (6,7):
|
| 33 |
+
raise RuntimeError('سجل يوتيوب غير صالح؛ توقف آمن لمنع تكرار الأحداث')
|
| 34 |
+
if self.data['schema']==6:
|
| 35 |
+
self.data=self.migrate6(self.data)
|
| 36 |
+
self.change(lambda d:None)
|
| 37 |
+
@staticmethod
|
| 38 |
+
def migrate6(old):
|
| 39 |
+
"""V6 had a single current event and a single reusable stream. V7 keeps one event
|
| 40 |
+
per live session and one reusable ingestion stream per TikTok account. The old
|
| 41 |
+
stream is kept for the account that was using it (legacy marker still accepted)."""
|
| 42 |
+
events={};streams={}
|
| 43 |
+
cur=old.get('current')
|
| 44 |
+
if isinstance(cur,dict) and cur.get('session_key') and cur.get('username'):
|
| 45 |
+
events[cur['session_key']]=cur
|
| 46 |
+
if old.get('stream_id') or old.get('stream_pending'):
|
| 47 |
+
streams[cur['username']]={'id':old.get('stream_id'),'pending':bool(old.get('stream_pending')),'legacy':True}
|
| 48 |
+
settings=old.get('settings') if isinstance(old.get('settings'),dict) else {}
|
| 49 |
+
clean={**DEFAULT_SETTINGS,**settings}
|
| 50 |
+
if clean.get('title')==OLD_DEFAULT_TITLE:
|
| 51 |
+
# Untouched V6/V7 default title -> switch to V8 per-account default.
|
| 52 |
+
clean['title']=DEFAULT_SETTINGS['title'];clean['name']=''
|
| 53 |
+
return {'schema':7,'instance':old.get('instance') or uuid.uuid4().hex,'enabled':bool(old.get('enabled')),
|
| 54 |
+
'channel':old.get('channel'),'streams':streams,'events':events,
|
| 55 |
+
'history':old.get('history') if isinstance(old.get('history'),list) else [],
|
| 56 |
+
'budget':old.get('budget') if isinstance(old.get('budget'),dict) else {},
|
| 57 |
+
'settings':clean}
|
| 58 |
+
def read(self):
|
| 59 |
+
with self.lock:return copy.deepcopy(self.data)
|
| 60 |
+
def change(self,fn):
|
| 61 |
+
with self.lock:
|
| 62 |
+
new=copy.deepcopy(self.data);fn(new)
|
| 63 |
+
tmp=self.path.with_suffix('.tmp')
|
| 64 |
+
tmp.write_text(json.dumps(new,ensure_ascii=False),encoding='utf-8')
|
| 65 |
+
tmp.replace(self.path)
|
| 66 |
+
self.data=new
|
| 67 |
+
def reserve(self,cost):
|
| 68 |
+
day=datetime.now(ZoneInfo('America/Los_Angeles')).strftime('%Y-%m-%d')
|
| 69 |
+
def mutate(d):
|
| 70 |
+
budget=d.get('budget',{})
|
| 71 |
+
if budget.get('day')!=day:budget={'day':day,'estimated_units':0}
|
| 72 |
+
if budget['estimated_units']+cost>int(os.getenv('YOUTUBE_DAILY_BUDGET','8000')):
|
| 73 |
+
raise APIError(429,'localDailyBudgetReached')
|
| 74 |
+
budget['estimated_units']+=cost;d['budget']=budget
|
| 75 |
+
self.change(mutate)
|
| 76 |
+
|
| 77 |
+
class GoogleAPI:
|
| 78 |
+
def __init__(self,journal):
|
| 79 |
+
self.journal=journal;self.access=None;self.expires=0;self.lock=threading.Lock()
|
| 80 |
+
def configured(self):
|
| 81 |
+
return all(os.getenv(k,'').strip() for k in ('GOOGLE_CLIENT_ID','GOOGLE_CLIENT_SECRET','YOUTUBE_REFRESH_TOKEN'))
|
| 82 |
+
def _request(self,url,method='GET',data=None,headers=None):
|
| 83 |
+
# FIX C: retry transient TLS/network failures instead of single urlopen(timeout=25)
|
| 84 |
+
# Any ephemeral SSLError/URLError previously bubbled as a generic "internal error"
|
| 85 |
+
# and left a sticky note for 40 minutes. Now we retry with exponential backoff
|
| 86 |
+
# and only then surface networkOrInvalidResponse.
|
| 87 |
+
max_attempts = 3
|
| 88 |
+
for attempt in range(max_attempts):
|
| 89 |
+
req=Request(url,data=data,headers=headers or {},method=method)
|
| 90 |
+
try:
|
| 91 |
+
with urlopen(req,timeout=25) as response:
|
| 92 |
+
raw=response.read();return json.loads(raw) if raw else {}
|
| 93 |
+
except HTTPError as e:
|
| 94 |
+
# Retry on transient 5xx / 429 (quota) before failing.
|
| 95 |
+
if e.code in (429, 500, 502, 503, 504) and attempt < max_attempts - 1:
|
| 96 |
+
time.sleep(1.2 * (attempt + 1))
|
| 97 |
+
continue
|
| 98 |
+
try:
|
| 99 |
+
err=json.loads(e.read()).get('error',{})
|
| 100 |
+
if isinstance(err,str):reason=err
|
| 101 |
+
else:reason=(err.get('errors') or [{}])[0].get('reason') or err.get('status') or 'httpError'
|
| 102 |
+
except Exception:reason='httpError'
|
| 103 |
+
raise APIError(e.code,reason) from None
|
| 104 |
+
except (URLError, TimeoutError, OSError, ValueError, ssl.SSLError, ssl.SSLWantReadError, ssl.SSLWantWriteError, ssl.SSLEOFError) as e:
|
| 105 |
+
if attempt < max_attempts - 1:
|
| 106 |
+
# exponential backoff: 1s, 2s
|
| 107 |
+
time.sleep(1 * (2 ** attempt))
|
| 108 |
+
continue
|
| 109 |
+
raise APIError(None,'networkOrInvalidResponse') from None
|
| 110 |
+
except Exception:
|
| 111 |
+
# Unexpected parse error etc. Don't retry silently; treat as network.
|
| 112 |
+
if attempt < max_attempts - 1:
|
| 113 |
+
time.sleep(1 * (2 ** attempt))
|
| 114 |
+
continue
|
| 115 |
+
raise APIError(None,'networkOrInvalidResponse') from None
|
| 116 |
+
raise APIError(None,'networkOrInvalidResponse')
|
| 117 |
+
def token(self):
|
| 118 |
+
with self.lock:
|
| 119 |
+
if self.access and time.time()<self.expires:return self.access
|
| 120 |
+
if not self.configured():raise APIError(401,'missingOAuthSecrets')
|
| 121 |
+
fields={'client_id':os.environ['GOOGLE_CLIENT_ID'].strip(),'client_secret':os.environ['GOOGLE_CLIENT_SECRET'].strip(),
|
| 122 |
+
'refresh_token':os.environ['YOUTUBE_REFRESH_TOKEN'].strip(),'grant_type':'refresh_token'}
|
| 123 |
+
result=self._request('https://oauth2.googleapis.com/token','POST',urlencode(fields).encode(),{'Content-Type':'application/x-www-form-urlencoded'})
|
| 124 |
+
if not result.get('access_token'):raise APIError(401,'missingAccessToken')
|
| 125 |
+
self.access=result['access_token'];self.expires=time.time()+max(30,int(result.get('expires_in',3600))-90)
|
| 126 |
+
return self.access
|
| 127 |
+
def call(self,resource,method='GET',params=None,body=None):
|
| 128 |
+
token=self.token()
|
| 129 |
+
self.journal.reserve(1 if method=='GET' else 50) # conservative local estimate, not a Google quota meter
|
| 130 |
+
url='https://www.googleapis.com/youtube/v3/'+resource
|
| 131 |
+
if params:url+='?'+urlencode(params)
|
| 132 |
+
headers={'Authorization':'Bearer '+token,'Content-Type':'application/json'}
|
| 133 |
+
try:return self._request(url,method,json.dumps(body).encode() if body is not None else None,headers)
|
| 134 |
+
except APIError as e:
|
| 135 |
+
if e.status==401:self.access=None
|
| 136 |
+
raise
|
| 137 |
+
def items(self,resource,params,max_pages=10):
|
| 138 |
+
items=[];query=dict(params)
|
| 139 |
+
for _ in range(max_pages):
|
| 140 |
+
r=self.call(resource,params=query);items.extend(r.get('items',[]))
|
| 141 |
+
if not r.get('nextPageToken'):return items
|
| 142 |
+
query['pageToken']=r['nextPageToken']
|
| 143 |
+
raise APIError(None,'reconciliationPageLimit')
|
youtube_manager.py
ADDED
|
@@ -0,0 +1,698 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, TimebaseReset
|
| 12 |
+
from youtube_api import GoogleAPI, Journal, APIError
|
| 13 |
+
|
| 14 |
+
class ManagedYouTube:
|
| 15 |
+
def __init__(self,ffmpeg,observe,log,accounts,path,headers_for=None):
|
| 16 |
+
self.ffmpeg=ffmpeg;self.observe=observe;self.log=log;self.accounts=accounts
|
| 17 |
+
self.headers_for=headers_for or (lambda user:{})
|
| 18 |
+
self.journal=Journal(path);self.api=GoogleAPI(self.journal)
|
| 19 |
+
self.operations=threading.RLock()
|
| 20 |
+
self.manager_thread=None;self.manager_stop=threading.Event()
|
| 21 |
+
self.senders={} # session_key -> {'thread','stop','sender'}
|
| 22 |
+
self.destinations={} # username -> rtmps url (in-memory only, never persisted)
|
| 23 |
+
self.scan=max(15,int(os.getenv('YOUTUBE_SCAN_SECONDS','30')))
|
| 24 |
+
self.end_grace=max(30,int(os.getenv('YOUTUBE_END_GRACE','90')))
|
| 25 |
+
# Reuse one YouTube broadcast when TikTok moves the streamer to a new session. The stream
|
| 26 |
+
# key is persistent (isReusable) and the broadcast is created with enableAutoStop=false,
|
| 27 |
+
# so the broadcast stays resumable and the watch URL does not change. YOUTUBE_REUSE_IDLE
|
| 28 |
+
# is how long a broadcast with no source at all is held open before it is ended cleanly.
|
| 29 |
+
self.reuse=str(os.getenv('YOUTUBE_REUSE_BROADCAST','1')).strip().lower() not in ('0','false','off','no')
|
| 30 |
+
self.reuse_idle=max(120,int(os.getenv('YOUTUBE_REUSE_IDLE','600')))
|
| 31 |
+
self.resume_check=max(30,int(os.getenv('YOUTUBE_RESUME_CHECK','60')))
|
| 32 |
+
self.max_events=max(1,int(os.getenv('YOUTUBE_MAX_EVENTS','3')))
|
| 33 |
+
self.last_logged=None;self.status_text='متوقف'
|
| 34 |
+
def marker(self):return 'QUSAI-AUTO-'+self.journal.read()['instance']
|
| 35 |
+
@staticmethod
|
| 36 |
+
def video_sources(sources):
|
| 37 |
+
mode=os.getenv('YOUTUBE_VIDEO_MODE','original').strip().lower()
|
| 38 |
+
if mode=='h264':return [s for s in sources if any(c in s['label'].lower() for c in ('h264','avc'))]
|
| 39 |
+
if mode!='original':raise RuntimeError('YOUTUBE_VIDEO_MODE يجب أن يكون original أو h264')
|
| 40 |
+
supported=[s for s in sources if any(c in s['label'].lower() for c in ('h264','avc','h265','hevc'))]
|
| 41 |
+
# Strict top source: no silent quality downgrade in original mode.
|
| 42 |
+
return supported[:1]
|
| 43 |
+
@staticmethod
|
| 44 |
+
def extract_hashtags(text):
|
| 45 |
+
"""Hashtags come from the TikTok live title itself, deduplicated, at most 12."""
|
| 46 |
+
seen=[];out=[]
|
| 47 |
+
for tag in re.findall(r'#[^\s#]+',text or ''):
|
| 48 |
+
if tag not in seen:seen.append(tag);out.append(tag)
|
| 49 |
+
if len(out)>=12:break
|
| 50 |
+
return ' '.join(out)[:400]
|
| 51 |
+
@staticmethod
|
| 52 |
+
def description_with_links(text,user,hashtags):
|
| 53 |
+
"""Per-account footer: the broadcaster's own TikTok profile + hashtags of this live.
|
| 54 |
+
No cross-account links, so concurrent events never mix attributions."""
|
| 55 |
+
text=text.rstrip()
|
| 56 |
+
link=f'https://www.tiktok.com/@{user}'
|
| 57 |
+
if link not in text:
|
| 58 |
+
text+=f'\n\n🎵 حساب المذيع على تيك توك\n@{user}\n{link}'
|
| 59 |
+
if hashtags and hashtags not in text:text+='\n\n'+hashtags
|
| 60 |
+
return re.sub(r'\n{3,}','\n\n',text)
|
| 61 |
+
|
| 62 |
+
# ---------- journal helpers ----------
|
| 63 |
+
def event_update(self,key,**kw):
|
| 64 |
+
def change(d):
|
| 65 |
+
ev=d['events'].get(key)
|
| 66 |
+
if ev is not None:ev.update(kw)
|
| 67 |
+
self.journal.change(change)
|
| 68 |
+
def event_note(self,key,text):self.event_update(key,note=text)
|
| 69 |
+
def streams_change(self,user,**kw):
|
| 70 |
+
def change(d):d['streams'].setdefault(user,{}).update(kw)
|
| 71 |
+
self.journal.change(change)
|
| 72 |
+
def set_status(self,text):self.status_text=text
|
| 73 |
+
@staticmethod
|
| 74 |
+
def safe_error(e):
|
| 75 |
+
if isinstance(e,(APIError,RuntimeError,ValueError)):return str(e)
|
| 76 |
+
# Keep the message generic (no raw network/traceback text can leak), but name the
|
| 77 |
+
# exception class. Without it a KeyError from a stale saved template is
|
| 78 |
+
# indistinguishable from any other internal fault and cannot be diagnosed.
|
| 79 |
+
return 'خطأ داخلي في إدارة يوتيوب؛ التوقف عن الإنشاء لحماية الأحداث ('+type(e).__name__+')'
|
| 80 |
+
def _event_error(self,key,e):
|
| 81 |
+
text=self.safe_error(e)
|
| 82 |
+
ev=self.journal.read()['events'].get(key) or {}
|
| 83 |
+
if text!=ev.get('note'):self.log(ev.get('username') or key,text)
|
| 84 |
+
self.event_note(key,text)
|
| 85 |
+
def _clear_stale_error_note(self,key):
|
| 86 |
+
"""FIX C: clear the sticky 'حماية الأحداث' / 'خطأ داخلي' note after recovery.
|
| 87 |
+
Previously _event_error wrote the note and nothing ever cleared it, so a transient
|
| 88 |
+
SSLError left the UI stuck for 40 min even though the stream was healthy."""
|
| 89 |
+
ev=self.journal.read()['events'].get(key)
|
| 90 |
+
if not ev:return
|
| 91 |
+
note=ev.get('note') or ''
|
| 92 |
+
if note and ('حماية الأحداث' in note or 'خطأ داخلي' in note or 'networkOrInvalidResponse' in note):
|
| 93 |
+
self.event_update(key, note='')
|
| 94 |
+
|
| 95 |
+
# ---------- channel / settings ----------
|
| 96 |
+
def check_channel(self):
|
| 97 |
+
result=self.api.call('channels',params={'part':'id,snippet','mine':'true'})
|
| 98 |
+
channels=result.get('items',[])
|
| 99 |
+
if len(channels)!=1:raise RuntimeError('لم نجد قناة واحدة مفوضة؛ أعد OAuth واختر قناة يوتيوب الصحيحة')
|
| 100 |
+
channel={'id':channels[0]['id'],'title':channels[0]['snippet']['title']}
|
| 101 |
+
old=self.journal.read()['channel']
|
| 102 |
+
if old and old['id']!=channel['id']:raise RuntimeError('القناة المفوضة تغيرت؛ توقف آمن لحماية أحداث القناة السابقة')
|
| 103 |
+
self.journal.change(lambda d:d.update(channel=channel))
|
| 104 |
+
return channel
|
| 105 |
+
def check(self):
|
| 106 |
+
with self.operations:return self.check_channel()
|
| 107 |
+
def settings(self,values):
|
| 108 |
+
with self.operations:
|
| 109 |
+
d=self.journal.read()
|
| 110 |
+
if d['enabled'] or d['events']:
|
| 111 |
+
raise ValueError('عطّل الأتمتة وانتظر إغلاق الأحداث قبل تعديل قوالب الأحداث')
|
| 112 |
+
clean={k:str(values.get(k,'')).strip() for k in ('name','privacy','title','description')}
|
| 113 |
+
if clean['privacy'] not in ('public','unlisted','private'):raise ValueError('خصوصية غير صالحة')
|
| 114 |
+
if len(clean['name'])>80:raise ValueError('الاسم الاحتياطي حتى 80 حرفًا (الاسم الفعلي يُجلب من حساب تيك توك)')
|
| 115 |
+
test={'name':'فلان','title':'بث تجريبي','hashtags':'#تجربة','date':'11-09-2026','time':'16:30:00','username':'example','room_id':'123',
|
| 116 |
+
'nickname':'فلان','live_title':'بث تجريبي'}
|
| 117 |
+
try:title=clean['title'].format(**test);desc=clean['description'].format(**test)
|
| 118 |
+
except (KeyError,ValueError,IndexError,AttributeError):raise ValueError('متغير قالب غير مدعوم')
|
| 119 |
+
if not title or len(title)>100 or any(c in title+desc for c in '<>') or len(desc)>4500:
|
| 120 |
+
raise ValueError('العنوان الناتج حتى 100 حرف والوصف حتى 4500، وبدون < أو >')
|
| 121 |
+
# Prevent arbitrary attribute/index lookups in format templates.
|
| 122 |
+
import string
|
| 123 |
+
for template in (clean['title'],clean['description']):
|
| 124 |
+
for _,field,spec,conv in string.Formatter().parse(template):
|
| 125 |
+
if field is not None and (field not in test or spec or conv):raise ValueError('استخدم متغيرات القالب البسيطة فقط')
|
| 126 |
+
self.journal.change(lambda x:x.update(settings=clean))
|
| 127 |
+
def snapshot(self):
|
| 128 |
+
d=self.journal.read()
|
| 129 |
+
events=[]
|
| 130 |
+
for key,ev in d['events'].items():
|
| 131 |
+
entry=self.senders.get(key)
|
| 132 |
+
s=entry['sender'].snapshot() if entry and entry['thread'].is_alive() else {}
|
| 133 |
+
events.append({'username':ev['username'],'nickname':ev.get('nickname',''),'room_id':ev['room_id'],
|
| 134 |
+
'title':ev['title'],'phase':ev.get('phase'),'youtube_status':ev.get('youtube_status'),
|
| 135 |
+
'closing':bool(ev.get('closing')),'broadcast_id':ev['broadcast_id'],'note':ev.get('note',''),
|
| 136 |
+
'status':s.get('status',''),'source':s.get('source',''),'output_seconds':s.get('output_seconds',0),
|
| 137 |
+
# YouTube's own verdict on the inbound stream, so the UI can show why
|
| 138 |
+
# viewers buffer instead of us guessing at the cause.
|
| 139 |
+
'youtube_health':ev.get('youtube_health',''),'youtube_issues':ev.get('youtube_issues','')})
|
| 140 |
+
# Effective ingestion mode, including the automatic anti-flap fallback, so the
|
| 141 |
+
# UI can show whether the constant-canvas protection has kicked in.
|
| 142 |
+
try:
|
| 143 |
+
probe_sender=Sender(self.ffmpeg,self.log,self.headers_for)
|
| 144 |
+
send_mode=probe_sender.send_mode()
|
| 145 |
+
except Exception:
|
| 146 |
+
send_mode=os.getenv('YOUTUBE_SEND_MODE','copy')
|
| 147 |
+
return {'status':self.status_text,'automatic':d['enabled'],'events':events,
|
| 148 |
+
'video_mode':os.getenv('YOUTUBE_VIDEO_MODE','original'),'configured':self.api.configured(),
|
| 149 |
+
'send_mode':send_mode,
|
| 150 |
+
'watched_accounts':list(self.accounts()),'channel':d['channel'],'settings':d['settings'],
|
| 151 |
+
'budget':d['budget'],'history_count':len(d['history']),'max_events':self.max_events}
|
| 152 |
+
|
| 153 |
+
# ---------- enable / disable / lifecycle ----------
|
| 154 |
+
def enable(self,expected_channel):
|
| 155 |
+
if os.getenv('YOUTUBE_VIDEO_MODE','original').strip().lower() not in ('original','h264'):raise ValueError('YOUTUBE_VIDEO_MODE يجب أن يكون original أو h264')
|
| 156 |
+
with self.operations:
|
| 157 |
+
if not self.api.configured():raise ValueError('أسرار OAuth الثلاثة غير مكتملة')
|
| 158 |
+
channel=self.check_channel()
|
| 159 |
+
if not expected_channel or expected_channel!=channel['id']:raise ValueError('افحص الربط وأكّد القناة الصحيحة قبل التفعيل')
|
| 160 |
+
if any(e.get('closing') for e in self.journal.read()['events'].values()):
|
| 161 |
+
raise ValueError('توجد أحداث قيد الإنهاء؛ انتظر اكتمال تنظيفها')
|
| 162 |
+
self.journal.change(lambda d:d.update(enabled=True));self.launch()
|
| 163 |
+
def disable(self):
|
| 164 |
+
with self.operations:
|
| 165 |
+
def change(d):
|
| 166 |
+
d['enabled']=False
|
| 167 |
+
for ev in d['events'].values():ev['closing']=True
|
| 168 |
+
self.journal.change(change)
|
| 169 |
+
for key in list(self.senders):self.stop_sender(key)
|
| 170 |
+
self.launch()
|
| 171 |
+
self.set_status('تعطيل الأتمتة وإنهاء الأحداث الجارية')
|
| 172 |
+
def launch(self):
|
| 173 |
+
if self.manager_thread and self.manager_thread.is_alive():return
|
| 174 |
+
self.manager_stop=threading.Event()
|
| 175 |
+
self.manager_thread=threading.Thread(target=self.run_manager,daemon=True);self.manager_thread.start()
|
| 176 |
+
def restore(self):
|
| 177 |
+
d=self.journal.read()
|
| 178 |
+
if d['enabled'] or d['events']:
|
| 179 |
+
self.launch()
|
| 180 |
+
def stop(self):
|
| 181 |
+
# Process shutdown is not a user-requested end. Preserve remote events for restart recovery.
|
| 182 |
+
self.manager_stop.set()
|
| 183 |
+
for key in list(self.senders):self.stop_sender(key)
|
| 184 |
+
|
| 185 |
+
# ---------- remote helpers ----------
|
| 186 |
+
def remote(self,resource,rid):
|
| 187 |
+
items=self.api.call(resource,params={'part':'id,snippet,status,contentDetails'+(',cdn' if resource=='liveStreams' else ''),'id':rid}).get('items',[])
|
| 188 |
+
return items[0] if items else None
|
| 189 |
+
def reconcile(self,resource,marker):
|
| 190 |
+
params={'part':'id,snippet,status,contentDetails','mine':'true','maxResults':50}
|
| 191 |
+
if resource=='liveBroadcasts':
|
| 192 |
+
params.pop('mine');params.update(broadcastStatus='all',broadcastType='all')
|
| 193 |
+
items=self.api.items(resource,params)
|
| 194 |
+
matches=[i for i in items if marker in i.get('snippet',{}).get('description','').splitlines()]
|
| 195 |
+
if len(matches)>1:raise RuntimeError('أحداث متكررة بنفس علامة التطبيق؛ توقف للمراجعة دون حذف تلقائي')
|
| 196 |
+
return matches[0] if matches else None
|
| 197 |
+
|
| 198 |
+
# ---------- per-account ingestion streams ----------
|
| 199 |
+
def stream_markers(self,user,legacy):
|
| 200 |
+
markers=[self.marker()+'-STREAM-'+user]
|
| 201 |
+
if legacy:markers.append(self.marker()+'-STREAM') # stream migrated from V6/V7
|
| 202 |
+
return markers
|
| 203 |
+
def ensure_stream(self,user):
|
| 204 |
+
d=self.journal.read();st=d['streams'].get(user) or {}
|
| 205 |
+
markers=self.stream_markers(user,bool(st.get('legacy')))
|
| 206 |
+
if not st.get('id'):
|
| 207 |
+
if st.get('pending'):
|
| 208 |
+
found=None
|
| 209 |
+
for m in markers:
|
| 210 |
+
found=self.reconcile('liveStreams',m)
|
| 211 |
+
if found:break
|
| 212 |
+
if not found:raise RuntimeError('إنشاء مصدر سابق غير محسوم؛ ننتظر المصالحة بدل إنشاء مفتاح مكرر')
|
| 213 |
+
self.streams_change(user,id=found['id'],pending=False)
|
| 214 |
+
else:
|
| 215 |
+
self.streams_change(user,pending=True)
|
| 216 |
+
try:
|
| 217 |
+
r=self.api.call('liveStreams','POST',{'part':'snippet,cdn,contentDetails'},
|
| 218 |
+
{'snippet':{'title':user+' — مصدر بث التطبيق القابل لإعادة الاستخدام','description':markers[0]},
|
| 219 |
+
'cdn':{'ingestionType':'rtmp','resolution':'variable','frameRate':'variable'},'contentDetails':{'isReusable':True}})
|
| 220 |
+
except APIError as e:
|
| 221 |
+
if e.status and 400<=e.status<500:self.streams_change(user,pending=False)
|
| 222 |
+
raise
|
| 223 |
+
if not r.get('id'):raise RuntimeError('استجابة إنشاء المصدر غير محسومة؛ لن نكرر الإنشاء')
|
| 224 |
+
self.streams_change(user,id=r['id'],pending=False)
|
| 225 |
+
stream=self.remote('liveStreams',self.journal.read()['streams'][user]['id'])
|
| 226 |
+
if not stream or not any(m in stream.get('snippet',{}).get('description','').splitlines() for m in markers):
|
| 227 |
+
raise RuntimeError('مصدر التطبيق مفقود أو هويته تغيرت؛ لا ننشئ مفاتيح بديلة تلقائيًا')
|
| 228 |
+
ingest=stream.get('cdn',{}).get('ingestionInfo',{});key=ingest.get('streamName')
|
| 229 |
+
secure=ingest.get('rtmpsIngestionAddress')
|
| 230 |
+
if not secure:
|
| 231 |
+
# YouTube's secure endpoint; streamName remains in memory only.
|
| 232 |
+
secure='rtmps://a.rtmps.youtube.com:443/live2'
|
| 233 |
+
p=urlparse(secure)
|
| 234 |
+
if p.scheme!='rtmps' or not p.hostname or not p.hostname.endswith('.youtube.com') or not key:
|
| 235 |
+
raise RuntimeError('لم يُرجع يوتيوب عنوان إرسال آمنًا صالحًا')
|
| 236 |
+
self.destinations[user]=secure.rstrip('/')+'/'+key
|
| 237 |
+
return stream
|
| 238 |
+
|
| 239 |
+
# ---------- events ----------
|
| 240 |
+
def open_event(self,user,observation):
|
| 241 |
+
d=self.journal.read();key=user+':'+observation['room_id']
|
| 242 |
+
if key in d['events'] or any(h['session_key']==key for h in d['history']):return False
|
| 243 |
+
dt=datetime.now(ZoneInfo('Asia/Baghdad'));settings=d['settings']
|
| 244 |
+
nickname=str(observation.get('nickname') or '').strip()
|
| 245 |
+
live_title=str(observation.get('live_title') or '').strip()
|
| 246 |
+
name=nickname or user
|
| 247 |
+
title_value=live_title or (name+' — بث مباشر')
|
| 248 |
+
hashtags=self.extract_hashtags(live_title)
|
| 249 |
+
values={'name':name,'title':title_value,'hashtags':hashtags,'date':dt.strftime('%d-%m-%Y'),
|
| 250 |
+
'time':dt.strftime('%H:%M:%S'),'username':user,'room_id':observation['room_id'],
|
| 251 |
+
# Legacy aliases. Journals written by older builds keep their saved
|
| 252 |
+
# templates, and those used {nickname}/{live_title}. Without these the
|
| 253 |
+
# format() below raises KeyError, which safe_error() masks into a generic
|
| 254 |
+
# "internal error" — so no event is ever created and nothing goes to air.
|
| 255 |
+
'nickname':name,'live_title':live_title}
|
| 256 |
+
title=settings['title'].format(**values)[:100];marker=self.marker()+'-EVENT-'+uuid.uuid4().hex
|
| 257 |
+
desc=self.description_with_links(settings['description'].format(**values)[:4400],user,hashtags)+'\n\n'+marker
|
| 258 |
+
ev={'username':user,'room_id':observation['room_id'],'session_key':key,'marker':marker,
|
| 259 |
+
'nickname':name,'live_title':live_title,'title':title,'description':desc,
|
| 260 |
+
'privacy':settings['privacy'],'created_at':time.time(),'note':'',
|
| 261 |
+
'broadcast_id':None,'create_pending':False,'phase':'planned','bound':False,'was_live':False,
|
| 262 |
+
'closing':False,'last_poll':0,'offline_since':None,'offline_count':0}
|
| 263 |
+
def change(d):d['events'][key]=ev
|
| 264 |
+
self.journal.change(change);return True
|
| 265 |
+
def ensure_broadcast(self,key):
|
| 266 |
+
ev=self.journal.read()['events'][key]
|
| 267 |
+
if not ev['broadcast_id']:
|
| 268 |
+
if ev['create_pending']:
|
| 269 |
+
found=self.reconcile('liveBroadcasts',ev['marker'])
|
| 270 |
+
if not found:raise RuntimeError('إنشاء حدث سابق غير محسوم؛ لا نكرر POST لمنع التراكم. يلزم مراجعة إذا استمر')
|
| 271 |
+
self.event_update(key,broadcast_id=found['id'],create_pending=False,phase='created')
|
| 272 |
+
else:
|
| 273 |
+
self.event_update(key,create_pending=True,phase='creating')
|
| 274 |
+
body={'snippet':{'title':ev['title'],'description':ev['description'],
|
| 275 |
+
'scheduledStartTime':(datetime.now(timezone.utc)+timedelta(seconds=30)).isoformat()},
|
| 276 |
+
'status':{'privacyStatus':ev['privacy']},
|
| 277 |
+
'contentDetails':{'enableAutoStart':False,'enableAutoStop':False,'enableDvr':True,
|
| 278 |
+
'recordFromStart':True,'monitorStream':{'enableMonitorStream':False}}}
|
| 279 |
+
try:r=self.api.call('liveBroadcasts','POST',{'part':'snippet,status,contentDetails'},body)
|
| 280 |
+
except APIError as e:
|
| 281 |
+
if e.status and 400<=e.status<500:self.event_update(key,create_pending=False,phase='planned')
|
| 282 |
+
raise
|
| 283 |
+
if not r.get('id'):raise RuntimeError('استجابة إنشاء الحدث غير محسومة')
|
| 284 |
+
self.event_update(key,broadcast_id=r['id'],create_pending=False,phase='created')
|
| 285 |
+
ev=self.journal.read()['events'][key];remote=self.remote('liveBroadcasts',ev['broadcast_id'])
|
| 286 |
+
if not remote:return None
|
| 287 |
+
if ev['marker'] not in remote.get('snippet',{}).get('description','').splitlines():
|
| 288 |
+
raise RuntimeError('علامة ملكية حدث التطبيق تغيرت؛ توقف آمن دون تعديل الحدث')
|
| 289 |
+
if remote.get('status',{}).get('privacyStatus')!=ev['privacy']:
|
| 290 |
+
raise RuntimeError('خصوصية الحدث في يوتيوب لا تطابق المطلوبة؛ راجع قيود مشروع API قبل النشر')
|
| 291 |
+
if remote.get('status',{}).get('lifeCycleStatus') not in ('complete','revoked') and not ev['bound']:
|
| 292 |
+
stream_id=(self.journal.read()['streams'].get(ev['username']) or {}).get('id')
|
| 293 |
+
if remote.get('contentDetails',{}).get('boundStreamId')!=stream_id:
|
| 294 |
+
self.api.call('liveBroadcasts/bind','POST',{'part':'id,contentDetails','id':ev['broadcast_id'],'streamId':stream_id})
|
| 295 |
+
self.event_update(key,bound=True,phase='bound')
|
| 296 |
+
return remote
|
| 297 |
+
def archive_event(self,key,outcome):
|
| 298 |
+
def change(d):
|
| 299 |
+
ev=d['events'].pop(key,None)
|
| 300 |
+
if ev is None:return
|
| 301 |
+
d['history'].append({'session_key':ev['session_key'],'broadcast_id':ev['broadcast_id'],
|
| 302 |
+
'title':ev['title'],'username':ev['username'],'nickname':ev.get('nickname','')})
|
| 303 |
+
d['history'][-1].update(outcome=outcome,ended_at=time.time())
|
| 304 |
+
self.journal.change(change)
|
| 305 |
+
self.stop_sender(key)
|
| 306 |
+
def finish_event(self,key):
|
| 307 |
+
ev=self.journal.read()['events'].get(key)
|
| 308 |
+
if not ev:return True
|
| 309 |
+
self.event_update(key,closing=True,phase='closing')
|
| 310 |
+
if not self.stop_sender(key):return False
|
| 311 |
+
ev=self.journal.read()['events'].get(key)
|
| 312 |
+
if not ev:return True
|
| 313 |
+
if not ev['broadcast_id']:
|
| 314 |
+
if ev['create_pending']:
|
| 315 |
+
found=self.reconcile('liveBroadcasts',ev['marker'])
|
| 316 |
+
if not found:raise RuntimeError('حدث غير محسوم عند الإنهاء؛ لا ننشئ أو نحذف بالتخمين')
|
| 317 |
+
self.event_update(key,broadcast_id=found['id'],create_pending=False)
|
| 318 |
+
else:self.archive_event(key,'cancelled_before_create');return True
|
| 319 |
+
ev=self.journal.read()['events'][key]
|
| 320 |
+
remote=self.remote('liveBroadcasts',ev['broadcast_id'])
|
| 321 |
+
if not remote:self.archive_event(key,'missing');return True
|
| 322 |
+
if ev['marker'] not in remote.get('snippet',{}).get('description','').splitlines():
|
| 323 |
+
raise RuntimeError('لا يمكن إنهاء حدث لا يحمل علامة هذه الجلسة')
|
| 324 |
+
status=remote.get('status',{}).get('lifeCycleStatus')
|
| 325 |
+
if status in ('complete','revoked'):
|
| 326 |
+
self.archive_event(key,status);return True
|
| 327 |
+
if status in ('live','testing'):
|
| 328 |
+
self.api.call('liveBroadcasts/transition','POST',{'part':'id,status','id':ev['broadcast_id'],'broadcastStatus':'complete'})
|
| 329 |
+
self.event_note(key,'طلب إنهاء الحدث أُرسل؛ التحقق من اكتماله');return False
|
| 330 |
+
# Never delete an event which actually started, even if local state missed the transition.
|
| 331 |
+
if status in ('created','ready') and not ev['was_live'] and not remote.get('snippet',{}).get('actualStartTime'):
|
| 332 |
+
self.api.call('liveBroadcasts','DELETE',{'id':ev['broadcast_id']})
|
| 333 |
+
self.archive_event(key,'empty_deleted');return True
|
| 334 |
+
self.event_note(key,'انتظار اكتمال انتقال حالة الحدث قبل إنهائه');return False
|
| 335 |
+
|
| 336 |
+
# ---------- senders ----------
|
| 337 |
+
def start_sender(self,key):
|
| 338 |
+
# FIX B: guard against 187 storm — don't push to RTMPS before YouTube ingest exists
|
| 339 |
+
# Previously called unconditionally from tick()/drive_event before broadcast was bound/ready,
|
| 340 |
+
# causing 7 ffmpeg failures in 5 min and 5:22 late start (~116s waste).
|
| 341 |
+
entry=self.senders.get(key)
|
| 342 |
+
if entry and entry['thread'].is_alive():return
|
| 343 |
+
ev=self.journal.read()['events'].get(key)
|
| 344 |
+
if not ev or ev.get('closing'):return
|
| 345 |
+
# Must be bound and have a destination; otherwise RTMPS will reject with 187
|
| 346 |
+
if not ev.get('bound') or not ev.get('broadcast_id'):
|
| 347 |
+
return
|
| 348 |
+
if ev.get('youtube_status') in ('complete','revoked','created'):
|
| 349 |
+
return
|
| 350 |
+
destination=self.destinations.get(ev['username'])
|
| 351 |
+
if not destination:raise RuntimeError('عنوان إرسال الحدث غير مهيأ')
|
| 352 |
+
stop=threading.Event();sender=Sender(self.ffmpeg,self.log,self.headers_for)
|
| 353 |
+
thread=threading.Thread(target=self.sender_loop,
|
| 354 |
+
args=(key,ev['username'],ev['room_id'],destination,stop,sender),daemon=True)
|
| 355 |
+
self.senders[key]={'thread':thread,'stop':stop,'sender':sender}
|
| 356 |
+
thread.start()
|
| 357 |
+
def stop_sender(self,key):
|
| 358 |
+
entry=self.senders.get(key)
|
| 359 |
+
if not entry:return True
|
| 360 |
+
entry['stop'].set()
|
| 361 |
+
entry['sender'].terminate()
|
| 362 |
+
if entry['thread'].is_alive():
|
| 363 |
+
entry['thread'].join(timeout=3)
|
| 364 |
+
if entry['thread'].is_alive():return False
|
| 365 |
+
self.senders.pop(key,None)
|
| 366 |
+
return True
|
| 367 |
+
def sender_loop(self,key,user,room_id,destination,stop,sender):
|
| 368 |
+
restart_at=0.0
|
| 369 |
+
while not stop.is_set():
|
| 370 |
+
try:
|
| 371 |
+
obs=self.observe(user)
|
| 372 |
+
if obs['status']!='live' or obs['room_id']!=room_id:return
|
| 373 |
+
sources=self.video_sources(obs['sources'])
|
| 374 |
+
for candidate in sources:
|
| 375 |
+
if stop.is_set():return
|
| 376 |
+
try:
|
| 377 |
+
if sender.send(user,candidate,destination,stop):break
|
| 378 |
+
except TimebaseReset:
|
| 379 |
+
# The source restarted its clock (TikTok CDN edge switch / stream restart).
|
| 380 |
+
# Keeping the session would repeat the last frames and drop about half the
|
| 381 |
+
# audio packets; a fresh session is the only fix. Measured in the lab:
|
| 382 |
+
# 566/1130 audio packets lost without this, 1132/1130 with it.
|
| 383 |
+
self.log(user,'أعاد مصدر تيك تو�� تعيين طوابع الزمن؛ فتح جلسة إرسال جديدة لتفادي تكرار الصورة وضياع الصوت')
|
| 384 |
+
restart_at=time.monotonic();break
|
| 385 |
+
except FormatChanged:
|
| 386 |
+
# Source resolution/layout changed (PK round started or ended, app
|
| 387 |
+
# resumed). Reconnect straight away so YouTube re-detects the new size.
|
| 388 |
+
sender.note_format_change()
|
| 389 |
+
self.log(user,'تغيرت أبعاد صورة المصدر أثناء البث؛ إعادة الاتصال فورًا بمقاس جديد')
|
| 390 |
+
restart_at=time.monotonic();break
|
| 391 |
+
except Exception:self.log(user,'تعثر إرسال الفيديو؛ نفس الحدث سيُستخدم')
|
| 392 |
+
except Exception:self.log(user,'تعذر تحديث مصدر الإرسال؛ لا ننشئ حدثًا إضافيًا')
|
| 393 |
+
# Skip the idle wait right after a resolution change; otherwise keep polling.
|
| 394 |
+
if restart_at and time.monotonic()-restart_at<30:restart_at=0.0;continue
|
| 395 |
+
stop.wait(15)
|
| 396 |
+
def probe(self,user,candidate):
|
| 397 |
+
# Check a readable FLV header before creating any public scheduled event.
|
| 398 |
+
r=requests.get(candidate['url'],impersonate='chrome',
|
| 399 |
+
headers={'Referer':'https://www.tiktok.com/',**self.headers_for(user)},stream=True,timeout=20)
|
| 400 |
+
try:
|
| 401 |
+
r.raise_for_status();prefix=b''
|
| 402 |
+
for chunk in r.iter_content():
|
| 403 |
+
prefix+=chunk
|
| 404 |
+
if len(prefix)>=3:return prefix[:3]==b'FLV'
|
| 405 |
+
return False
|
| 406 |
+
finally:r.close()
|
| 407 |
+
def _probe_ok(self,user,candidate):
|
| 408 |
+
# Why the probe failed matters: a blocked CDN edge, an expired URL and a non-FLV
|
| 409 |
+
# answer all look identical from the outside, and without the reason the account
|
| 410 |
+
# is skipped in silence (which is how a live stream ends up never broadcast).
|
| 411 |
+
try:
|
| 412 |
+
ok=self.probe(user,candidate)
|
| 413 |
+
self._probe_note='' if ok else 'الرابط لا يبدأ بترويسة FLV'
|
| 414 |
+
return ok
|
| 415 |
+
except Exception as e:
|
| 416 |
+
self._probe_note=self.safe_error(e);return False
|
| 417 |
+
|
| 418 |
+
def _republish_allowed(self,key,entry):
|
| 419 |
+
"""True when the earlier broadcast of this very session is over (so re-publishing is right)."""
|
| 420 |
+
if time.time()-float(entry.get('ended_at') or 0)<float(os.getenv('YOUTUBE_REPUBLISH_AFTER','120')):
|
| 421 |
+
return False # too soon: the old session may still be settling
|
| 422 |
+
bid=entry.get('broadcast_id')
|
| 423 |
+
if not bid:return True
|
| 424 |
+
try:item=self.remote('liveBroadcasts',bid)
|
| 425 |
+
except Exception:return False # cannot verify: never risk a duplicate broadcast
|
| 426 |
+
if not item:return True
|
| 427 |
+
life=((item.get('status') or {}).get('lifeCycleStatus') or '')
|
| 428 |
+
return life not in ('live','liveStarting','testing')
|
| 429 |
+
|
| 430 |
+
def _skip_note(self,user,text):
|
| 431 |
+
# Log each distinct reason once per account: silence is what makes this class of
|
| 432 |
+
# problem undiagnosable.
|
| 433 |
+
last=getattr(self,'_skip_last',None) or {}
|
| 434 |
+
if last.get(user)==text:return
|
| 435 |
+
last[user]=text;self._skip_last=last;self.log(user,text)
|
| 436 |
+
|
| 437 |
+
def _broadcast_open(self,key,ev):
|
| 438 |
+
"""True when YouTube still holds this broadcast open (so a re-attached encoder resumes
|
| 439 |
+
the *same* watch URL instead of YouTube handing us a new one). The answer only changes
|
| 440 |
+
when YouTube archives the broadcast, which takes minutes, so it is verified at most once
|
| 441 |
+
per YOUTUBE_RESUME_CHECK seconds to stay inside the quota budget."""
|
| 442 |
+
if not self.reuse or not ev.get('bound') or not ev.get('broadcast_id'):return False
|
| 443 |
+
now=time.time()
|
| 444 |
+
if now-float(ev.get('resume_check_at') or 0)<self.resume_check:return bool(ev.get('resume_ok'))
|
| 445 |
+
try:item=self.remote('liveBroadcasts',ev['broadcast_id'])
|
| 446 |
+
except Exception:
|
| 447 |
+
# A transport error is not a verdict on the broadcast: keep the last known answer
|
| 448 |
+
# (bounded anyway by YOUTUBE_REUSE_IDLE) instead of ending a resumable broadcast.
|
| 449 |
+
self.event_update(key,resume_check_at=now)
|
| 450 |
+
return bool(ev.get('resume_ok'))
|
| 451 |
+
life=((item or {}).get('status') or {}).get('lifeCycleStatus') or ''
|
| 452 |
+
ok=bool(item) and life not in ('complete','revoked')
|
| 453 |
+
self.event_update(key,resume_check_at=now,resume_ok=ok,youtube_status=life or ev.get('youtube_status'))
|
| 454 |
+
return ok
|
| 455 |
+
|
| 456 |
+
# ---------- main loop ----------
|
| 457 |
+
def tick(self):
|
| 458 |
+
d=self.journal.read()
|
| 459 |
+
accounts=list(self.accounts())
|
| 460 |
+
# 1) Close events that must close (user stop, disable, account removed).
|
| 461 |
+
for key,ev in list(d['events'].items()):
|
| 462 |
+
if ev.get('closing') or not d['enabled'] or ev['username'] not in accounts:
|
| 463 |
+
try:self.finish_event(key)
|
| 464 |
+
except Exception as e:self._event_error(key,e)
|
| 465 |
+
d=self.journal.read()
|
| 466 |
+
if not d['enabled']:
|
| 467 |
+
self.set_status('تعطيل الأتمتة وإنهاء الأحداث الجارية' if d['events'] else 'الأتمتة معطلة')
|
| 468 |
+
return
|
| 469 |
+
# 2) Discover new live sessions, one event per account, up to the concurrency cap.
|
| 470 |
+
cap=self.max_events;active=len(d['events']);capped=False
|
| 471 |
+
for user in accounts:
|
| 472 |
+
if active>=cap:capped=True;break
|
| 473 |
+
if any(e['username']==user for e in d['events'].values()):continue
|
| 474 |
+
try:obs=self.observe(user)
|
| 475 |
+
except Exception as e:
|
| 476 |
+
self._skip_note(user,'تعذر رصد الجلسة: '+self.safe_error(e));continue
|
| 477 |
+
if obs['status']!='live':continue
|
| 478 |
+
key=user+':'+obs['room_id']
|
| 479 |
+
if key in d['events']:continue
|
| 480 |
+
entry=next((h for h in d['history'] if h.get('session_key')==key),None)
|
| 481 |
+
if entry is not None:
|
| 482 |
+
# The history entry stops a *duplicate* broadcast of one session. It must not
|
| 483 |
+
# keep a broadcaster off the air after the earlier broadcast died: an app
|
| 484 |
+
# restart archives the event while the streamer is still live, and the stream
|
| 485 |
+
# then never returns to YouTube.
|
| 486 |
+
if not self._republish_allowed(key,entry):
|
| 487 |
+
self._skip_note(user,'هذه الجلسة نُشرت سابقًا وبثّها السابق ما زال قائمًا؛ لا نكررها');continue
|
| 488 |
+
self.journal.change(lambda dd:dd.update(history=[h for h in dd['history'] if h.get('session_key')!=key]))
|
| 489 |
+
self.log(user,'بثّ سابق لنفس الجلسة انتهى؛ نعيد نشر الجلسة الحالية على يوتيوب')
|
| 490 |
+
candidates=self.video_sources(obs['sources'])
|
| 491 |
+
if not candidates:
|
| 492 |
+
self._skip_note(user,'لا روابط فيديو متاحة لهذه الجلسة');continue
|
| 493 |
+
if not any(self._probe_ok(user,c) for c in candidates):
|
| 494 |
+
self._skip_note(user,'فحص رابط الفيديو فشل: '+str(getattr(self,'_probe_note','') or 'بلا تفاصيل'));continue
|
| 495 |
+
try:
|
| 496 |
+
self.ensure_stream(user)
|
| 497 |
+
if self.open_event(user,obs):active+=1
|
| 498 |
+
except Exception as e:self.log(user,self.safe_error(e))
|
| 499 |
+
# 3) Drive every open event independently.
|
| 500 |
+
for key in list(self.journal.read()['events'].keys()):
|
| 501 |
+
try:self.drive_event(key)
|
| 502 |
+
except Exception as e:self._event_error(key,e)
|
| 503 |
+
d=self.journal.read()
|
| 504 |
+
if capped:self.set_status('بلغنا الحد الأقصى للأحداث المتزامنة ('+str(cap)+')؛ حسابات إضافية تنتظر فراغها')
|
| 505 |
+
elif d['events']:self.set_status(str(len(d['events']))+' حدث/أحداث يوتيوب جارية')
|
| 506 |
+
else:self.set_status('انتظار بث قابل للإرسال من الحسابات المفعلة')
|
| 507 |
+
def drive_event(self,key):
|
| 508 |
+
ev=self.journal.read()['events'].get(key)
|
| 509 |
+
if not ev or ev.get('closing'):return
|
| 510 |
+
user=ev['username']
|
| 511 |
+
obs=self.observe(user)
|
| 512 |
+
if obs['status']=='offline':
|
| 513 |
+
self.stop_sender(key)
|
| 514 |
+
ev=self.journal.read()['events'].get(key)
|
| 515 |
+
if not ev:return
|
| 516 |
+
count=int(ev.get('offline_count') or 0)+1;since=ev.get('offline_since') or time.time()
|
| 517 |
+
self.event_update(key,offline_count=count,offline_since=since)
|
| 518 |
+
gone=time.time()-float(since)
|
| 519 |
+
# Reuse mode: a broadcast which already went live stays open (enableAutoStop=false)
|
| 520 |
+
# so the streamer can come back — same session or a brand new one — on the same URL.
|
| 521 |
+
# The old 90s rule still closes broadcasts YouTube has already archived, or ones that
|
| 522 |
+
# were never public, and YOUTUBE_REUSE_IDLE keeps a sourceless event from lingering.
|
| 523 |
+
if ev.get('was_live') and gone<self.reuse_idle and self._broadcast_open(key,ev):
|
| 524 |
+
self.event_note(key,'المذيع غير متصل؛ نُبقي الحدث والرابط لإعادة الربط ('+str(int(max(0,self.reuse_idle-gone)//60))+' دقيقة متبقية)')
|
| 525 |
+
return
|
| 526 |
+
if count>=2 and gone>=self.end_grace:self.finish_event(key)
|
| 527 |
+
else:self.event_note(key,'تأكيد انتهاء جلسة تيك توك؛ مهلة قبل إنهاء يوتيوب')
|
| 528 |
+
return
|
| 529 |
+
if obs['status']!='live':raise RuntimeError('حالة تيك توك غير مؤكدة؛ لا ننهي الحدث بسبب خطأ رصد')
|
| 530 |
+
if obs['room_id']!=ev['room_id']:
|
| 531 |
+
# The broadcaster restarted the stream: TikTok hands out a new room id. If YouTube
|
| 532 |
+
# still holds our broadcast open we keep it and only move the encoder to the new
|
| 533 |
+
# source, so the watch URL does not change. Otherwise the old path applies.
|
| 534 |
+
if ev.get('was_live') and self._broadcast_open(key,ev):
|
| 535 |
+
self.stop_sender(key)
|
| 536 |
+
self.event_update(key,room_id=obs['room_id'],reused=int(ev.get('reused') or 0)+1,
|
| 537 |
+
reuse_from=ev['room_id'],resume_check_at=0,
|
| 538 |
+
note='انتقلت جلسة تيك توك إلى غرفة جديدة؛ نُبقي نفس حدث يوتيوب ونعيد ربط الإرسال')
|
| 539 |
+
self.log(user,'انتقلت جلسة تيك توك إلى غرفة جديدة ('+str(obs['room_id'])+' ← '+str(ev['room_id'])+')؛ نُبقي نفس حدث يوتيوب '+str(ev.get('broadcast_id')))
|
| 540 |
+
ev=self.journal.read()['events'].get(key)
|
| 541 |
+
if not ev:return
|
| 542 |
+
else:
|
| 543 |
+
self.event_update(key,closing=True);self.finish_event(key);return
|
| 544 |
+
self.event_update(key,offline_since=None,offline_count=0)
|
| 545 |
+
# FIX C: clear sticky transient error note once TikTok is reachable again (recovery)
|
| 546 |
+
# Without this the 'التوقف عن الإنشاء لحماية الأحداث' message stayed 40 min.
|
| 547 |
+
if ev.get('note') and ('حماية الأحداث' in ev['note'] or 'خطأ داخلي' in ev['note'] or 'networkOrInvalidResponse' in ev['note']):
|
| 548 |
+
self._clear_stale_error_note(key)
|
| 549 |
+
ev=self.journal.read()['events'].get(key)
|
| 550 |
+
if not ev:return
|
| 551 |
+
if not self.video_sources(obs['sources']):
|
| 552 |
+
if not ev['was_live'] and time.time()-ev['created_at']>int(os.getenv('YOUTUBE_START_TIMEOUT','900')):
|
| 553 |
+
self.event_update(key,closing=True);self.finish_event(key)
|
| 554 |
+
else:self.event_note(key,'الجلسة مستمرة؛ انتظار المصدر المحدد للجودة')
|
| 555 |
+
return
|
| 556 |
+
if user not in self.destinations:
|
| 557 |
+
self.ensure_stream(user)
|
| 558 |
+
# recovery: stream succeeded after transient failure -> clear sticky note
|
| 559 |
+
ev=self.journal.read()['events'].get(key)
|
| 560 |
+
if ev and ev.get('note') and ('حماية الأحداث' in ev['note'] or 'خطأ داخلي' in ev['note']):
|
| 561 |
+
self._clear_stale_error_note(key)
|
| 562 |
+
ev=self.journal.read()['events'].get(key)
|
| 563 |
+
if not ev.get('bound'):
|
| 564 |
+
remote=self.ensure_broadcast(key)
|
| 565 |
+
if not remote:self.archive_event(key,'missing');return
|
| 566 |
+
ev=self.journal.read()['events'].get(key)
|
| 567 |
+
if not ev or not ev.get('bound'):
|
| 568 |
+
return
|
| 569 |
+
# FIX C: successful bind after transient TLS failure should clear the stuck note
|
| 570 |
+
if ev.get('note') and ('حماية الأحداث' in ev['note'] or 'خطأ داخلي' in ev['note']):
|
| 571 |
+
self._clear_stale_error_note(key)
|
| 572 |
+
ev=self.journal.read()['events'].get(key)
|
| 573 |
+
# FIX B: do NOT start sender here unconditionally. Fall through to gated check below
|
| 574 |
+
# which verifies YouTube lifecycle is ready/testing/live. This prevents the 187 storm
|
| 575 |
+
# that was caused by pushing to RTMPS before ingest existed (5:22 late, 116s waste).
|
| 576 |
+
|
| 577 |
+
# ---------- gated sender start (FIX B) ----------
|
| 578 |
+
ev=self.journal.read()['events'].get(key)
|
| 579 |
+
if not ev or not ev.get('broadcast_id') or not ev.get('bound'):
|
| 580 |
+
return
|
| 581 |
+
# Respect 45s poll interval for quota, but also gate sender on lifecycle status
|
| 582 |
+
# If we have a cached youtube_status we can gate without an extra API call
|
| 583 |
+
needs_poll = time.time()-float(ev.get('last_poll') or 0) >= 45
|
| 584 |
+
if needs_poll:
|
| 585 |
+
self.event_update(key,last_poll=time.time())
|
| 586 |
+
remote=self.remote('liveBroadcasts',ev['broadcast_id'])
|
| 587 |
+
if not remote:
|
| 588 |
+
self.event_update(key,closing=True);self.finish_event(key);return
|
| 589 |
+
if ev['marker'] not in remote.get('snippet',{}).get('description','').splitlines():
|
| 590 |
+
raise RuntimeError('تغيرت علامة الحدث؛ توقف آمن دون تعديل أحداث أخرى')
|
| 591 |
+
life=remote.get('status',{}).get('lifeCycleStatus');self.event_update(key,youtube_status=life)
|
| 592 |
+
ev=self.journal.read()['events'].get(key)
|
| 593 |
+
# FIX C: successful remote fetch means transport recovered -> clear stuck error
|
| 594 |
+
if ev.get('note') and ('حماية الأحداث' in ev['note'] or 'خطأ داخلي' in ev['note'] or 'network' in ev['note']):
|
| 595 |
+
self._clear_stale_error_note(key)
|
| 596 |
+
ev=self.journal.read()['events'].get(key)
|
| 597 |
+
else:
|
| 598 |
+
# Use cached status to gate sender without burning quota
|
| 599 |
+
life=ev.get('youtube_status')
|
| 600 |
+
# If status unknown or stale 'created', fetch once to avoid wrong gate
|
| 601 |
+
if not life or life == 'created':
|
| 602 |
+
remote=self.remote('liveBroadcasts',ev['broadcast_id'])
|
| 603 |
+
if not remote:
|
| 604 |
+
self.event_update(key,closing=True);self.finish_event(key);return
|
| 605 |
+
life=remote.get('status',{}).get('lifeCycleStatus');self.event_update(key,youtube_status=life)
|
| 606 |
+
ev=self.journal.read()['events'].get(key)
|
| 607 |
+
if ev.get('note') and ('حماية الأحداث' in ev['note'] or 'خطأ داخلي' in ev['note']):
|
| 608 |
+
self._clear_stale_error_note(key)
|
| 609 |
+
ev=self.journal.read()['events'].get(key)
|
| 610 |
+
else:
|
| 611 |
+
remote=None
|
| 612 |
+
|
| 613 |
+
if life in ('complete','revoked'):
|
| 614 |
+
self.event_update(key,closing=True);self.finish_event(key);return
|
| 615 |
+
if life=='live':
|
| 616 |
+
if not ev['was_live']:
|
| 617 |
+
self.event_update(key,was_live=True,phase='live')
|
| 618 |
+
self.log(user,'يوتيوب أكد أن الحدث مباشر: '+ev['broadcast_id'])
|
| 619 |
+
# Now safe to ensure sender is running; viewer already watching
|
| 620 |
+
self.start_sender(key)
|
| 621 |
+
self.stream_health(key,user,ev)
|
| 622 |
+
return
|
| 623 |
+
|
| 624 |
+
# life is expected to be 'ready' or 'testing' for a newly bound event
|
| 625 |
+
if life not in ('ready','testing'):
|
| 626 |
+
# 'created' etc. -> ingest not yet receivable, don't push (prevents 187)
|
| 627 |
+
if life == 'created':
|
| 628 |
+
self.event_note(key,'تم ربط الحدث؛ انتظار جاهزية الاستقبال قبل الإرسال')
|
| 629 |
+
else:
|
| 630 |
+
self.event_note(key,'انتظار تأكيد استقبال يوتيوب قبل بدء الحدث')
|
| 631 |
+
self.stop_sender(key)
|
| 632 |
+
return
|
| 633 |
+
|
| 634 |
+
# life is ready/testing -> we need to push so streamStatus becomes active, then transition
|
| 635 |
+
self.start_sender(key)
|
| 636 |
+
|
| 637 |
+
# Transition to live if YouTube already sees an active stream
|
| 638 |
+
# Only when we did a full poll; otherwise defer transition to next poll
|
| 639 |
+
if needs_poll:
|
| 640 |
+
stream_id=(self.journal.read()['streams'].get(user) or {}).get('id')
|
| 641 |
+
stream=self.remote('liveStreams',stream_id) if stream_id else None
|
| 642 |
+
if stream and stream.get('status',{}).get('streamStatus')=='active' and life in ('ready','testing'):
|
| 643 |
+
self.api.call('liveBroadcasts/transition','POST',{'part':'id,status','id':ev['broadcast_id'],'broadcastStatus':'live'})
|
| 644 |
+
self.event_update(key,phase='starting');self.event_note(key,'يوتيوب يستقبل؛ طُلب بدء الحدث للعامة حسب الخصوصية')
|
| 645 |
+
elif time.time()-ev['created_at']>int(os.getenv('YOUTUBE_START_TIMEOUT','900')) and not ev['was_live']:
|
| 646 |
+
self.event_update(key,closing=True);self.finish_event(key)
|
| 647 |
+
else:
|
| 648 |
+
# keep waiting note, but ensure error note is cleared
|
| 649 |
+
if ev.get('note') and ('حماية الأحداث' in ev['note'] or 'خطأ داخلي' in ev['note']):
|
| 650 |
+
self.event_update(key, note='انتظار تأكيد استقبال يوتيوب قبل بدء الحدث')
|
| 651 |
+
elif not ev.get('note'):
|
| 652 |
+
self.event_note(key,'انتظار تأكيد استقبال يوتيوب قبل بدء الحدث')
|
| 653 |
+
def stream_health(self,key,user,ev):
|
| 654 |
+
"""Read YouTube's own verdict on the inbound stream. The Live Streaming API reports
|
| 655 |
+
exactly why viewers buffer (videoIngestionStarved), why the picture can be rejected
|
| 656 |
+
(videoResolutionUnsupported / videoCodec) and why playback stalls (gopSizeLong /
|
| 657 |
+
openGop). Surfacing it replaces guesswork with ground truth. Polled on a slower
|
| 658 |
+
cadence than the life-cycle check so it stays inside the quota budget."""
|
| 659 |
+
interval=max(60,int(os.getenv('YOUTUBE_HEALTH_INTERVAL','180')))
|
| 660 |
+
if time.time()-float(ev.get('last_health') or 0)<interval:return
|
| 661 |
+
stream_id=(self.journal.read()['streams'].get(user) or {}).get('id')
|
| 662 |
+
if not stream_id:return
|
| 663 |
+
self.event_update(key,last_health=time.time())
|
| 664 |
+
stream=self.remote('liveStreams',stream_id)
|
| 665 |
+
if not stream:return
|
| 666 |
+
health=stream.get('status',{}).get('healthStatus') or {}
|
| 667 |
+
issues=[]
|
| 668 |
+
for i in (health.get('configurationIssues') or []):
|
| 669 |
+
sev=i.get('severity','')
|
| 670 |
+
if sev in ('warning','error'):
|
| 671 |
+
issues.append('%s [%s] %s'%(i.get('type',''),sev,i.get('reason','')))
|
| 672 |
+
text=' | '.join(issues)
|
| 673 |
+
prev=ev.get('last_issues')
|
| 674 |
+
self.event_update(key,youtube_health=health.get('status',''),youtube_issues=text,last_issues=text)
|
| 675 |
+
if text and text!=prev:
|
| 676 |
+
self.log(user,'يوتيوب يشخّص البث: '+text)
|
| 677 |
+
def run_manager(self):
|
| 678 |
+
checked=False
|
| 679 |
+
try:
|
| 680 |
+
while not self.manager_stop.is_set():
|
| 681 |
+
delay=self.scan
|
| 682 |
+
try:
|
| 683 |
+
with self.operations:
|
| 684 |
+
if not checked:self.check_channel();checked=True
|
| 685 |
+
self.tick()
|
| 686 |
+
d=self.journal.read()
|
| 687 |
+
if not d['enabled'] and not d['events']:self.set_status('الأتمتة معطلة؛ لا إرسال ولا إنشاء أحداث')
|
| 688 |
+
except Exception as e:
|
| 689 |
+
text=self.safe_error(e)
|
| 690 |
+
if not isinstance(e,(APIError,RuntimeError,ValueError)):text+=' ('+type(e).__name__+')'
|
| 691 |
+
self.set_status(text)
|
| 692 |
+
if text!=self.last_logged:self.log('youtube',text);self.last_logged=text
|
| 693 |
+
if isinstance(e,APIError) and (e.status in (401,403,429) or e.reason=='invalid_grant'):delay=300
|
| 694 |
+
else:delay=60
|
| 695 |
+
self.manager_stop.wait(delay)
|
| 696 |
+
finally:
|
| 697 |
+
for key in list(self.senders):self.stop_sender(key)
|
| 698 |
+
self.set_status('مدير الأحداث متوقف؛ حالة الجلسات محفوظة')
|