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
Files changed (12) hide show
  1. Dockerfile +61 -5
  2. README.md +13 -1
  3. app.py +541 -279
  4. flv_bridge.py +107 -0
  5. index.html +108 -0
  6. relay.py +194 -0
  7. requirements.txt +3 -0
  8. tiktok_auth.py +476 -0
  9. verify.sh +139 -0
  10. verify_tokens.py +152 -0
  11. youtube_api.py +143 -0
  12. youtube_manager.py +698 -0
Dockerfile CHANGED
@@ -1,9 +1,65 @@
1
- FROM python:3.9-slim
 
 
 
 
 
 
2
 
3
- RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
4
- RUN pip install --no-cache-dir yt-dlp psutil flask streamlink
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
- COPY app.py /app.py
 
 
 
 
 
 
7
 
 
8
  EXPOSE 7860
9
- CMD ["python", "/app.py"]
 
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
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
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 time
3
- import subprocess
4
- import threading
5
- import psutil
6
- from flask import Flask
7
-
8
- TIKTOK_USERNAME = "@abdulaziz999989"
9
- STREAM_KEY = "p3dq-5cex-bc1k-vet7-bsyx"
10
- FFMPEG_LOG = "/tmp/ffmpeg.log"
11
- MONITOR_LOG = "/tmp/monitor.log"
12
- LOCK_FILE = "/tmp/app_monitor_core.lock"
13
-
14
- TIKTOK_COOKIES = os.getenv("TIKTOK_COOKIES", "")
15
- TIKTOK_COOKIES_ADULT = os.getenv("TIKTOK_COOKIES_ADULT", "")
16
-
17
- def log(msg):
18
- ts = time.strftime("%Y-%m-%d %H:%M:%S")
19
- line = f"[{ts}] {msg}"
20
- print(line, flush=True)
21
- with open(MONITOR_LOG, "a", encoding="utf-8") as f:
22
- f.write(line + "\n")
23
-
24
- def save_cookies():
25
- if TIKTOK_COOKIES:
26
- with open("/tmp/tiktok_cookies.txt", "w") as f:
27
- f.write(TIKTOK_COOKIES)
28
- if TIKTOK_COOKIES_ADULT:
29
- with open("/tmp/tiktok_cookies_adult.txt", "w") as f:
30
- f.write(TIKTOK_COOKIES_ADULT)
31
-
32
- save_cookies()
33
-
34
- def kill_process_tree(pid):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  try:
36
- parent = psutil.Process(pid)
37
- for child in parent.children(recursive=True):
38
- child.kill()
39
- parent.kill()
40
- except:
41
- pass
42
-
43
- def get_tiktok_stream_url():
44
- # streamlink أولاً
45
- try:
46
- url = f"https://www.tiktok.com/{TIKTOK_USERNAME}/live"
47
- cmd = ["streamlink", "--stream-url", url, "best", "--retry-streams", "5", "--retry-open", "3"]
48
- if os.path.exists("/tmp/tiktok_cookies_adult.txt"):
49
- cmd.extend(["--cookies", "/tmp/tiktok_cookies_adult.txt"])
50
- elif os.path.exists("/tmp/tiktok_cookies.txt"):
51
- cmd.extend(["--cookies", "/tmp/tiktok_cookies.txt"])
52
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=25)
53
- if result.returncode == 0 and result.stdout.strip():
54
- return result.stdout.strip().split("\n")[0]
55
- except:
56
- pass
57
-
58
- # yt-dlp ثانياً
59
- try:
60
- url = f"https://www.tiktok.com/{TIKTOK_USERNAME}/live"
61
- cmd = [
62
- "yt-dlp", "--no-warnings", "--no-check-certificates",
63
- "--no-live-from-start", "--no-cache-dir", "--rm-cache-dir",
64
- "-f", "best", "--get-url", url
65
- ]
66
- if os.path.exists("/tmp/tiktok_cookies_adult.txt"):
67
- cmd.extend(["--cookies", "/tmp/tiktok_cookies_adult.txt"])
68
- elif os.path.exists("/tmp/tiktok_cookies.txt"):
69
- cmd.extend(["--cookies", "/tmp/tiktok_cookies.txt"])
70
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
71
- if result.returncode == 0 and result.stdout.strip():
72
- return result.stdout.strip().split("\n")[0]
73
- except:
74
- pass
75
- return None
76
-
77
- def start_stream(stream_key, stream_url):
78
- if not stream_key or not stream_url:
79
- return None
80
- log("🎬 بدء البث (فيديو نسخ + صوت احترافي مع إصلاح التزامن)...")
81
- rtmp_url = f"rtmp://a.rtmp.youtube.com/live2/{stream_key}"
82
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  try:
84
- subprocess.run(["killall", "-9", "ffmpeg", "yt-dlp", "streamlink"], stderr=subprocess.DEVNULL)
85
- except:
86
- pass
87
-
88
- cmd = [
89
- "nice", "-n", "19", "ffmpeg",
90
- "-hide_banner", "-loglevel", "warning",
91
- "-re",
92
- "-thread_queue_size", "2048",
93
- "-fflags", "+genpts",
94
- "-flags", "+global_header",
95
- "-i", stream_url,
96
-
97
- # فيديو: نسخ بدون أي تغيير
98
- "-c:v", "copy",
99
-
100
- # صوت: ترميز AAC 192kbps مع تنقية
101
- "-c:a", "aac",
102
- "-b:a", "192k",
103
- "-ar", "44100",
104
- "-profile:a", "aac_low",
105
- "-cutoff", "18000",
106
- "-af", "highpass=f=60,lowpass=f=17000,aresample=async=1:first_pts=0",
107
- "-async", "1",
108
-
109
- # إصلاح التزامن (يمنع تعليق "جارٍ تحضير البث")
110
- "-start_at_zero",
111
- "-copyts",
112
- "-vsync", "2",
113
-
114
- "-bsf:v", "h264_mp4toannexb",
115
- "-reconnect", "1",
116
- "-reconnect_at_eof", "1",
117
- "-reconnect_delay_max", "60",
118
- "-rtmp_live", "live",
119
- "-max_muxing_queue_size", "2048",
120
- "-f", "flv", rtmp_url
121
- ]
122
-
123
- with open(FFMPEG_LOG, "w") as stderr_file:
124
- proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL,
125
- stderr=stderr_file, preexec_fn=os.setsid)
126
- log(f"✅ ffmpeg PID: {proc.pid}")
127
- return proc
128
-
129
- current_status = {
130
- "live": False,
131
- "pid": None,
132
- "start_time": "",
133
- "last_check": "",
134
- "cpu_load": "?",
135
- "ram": "?",
136
- "ffmpeg_cpu": "?",
137
- }
138
-
139
- def monitor_loop():
140
- global current_status
141
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  try:
143
- subprocess.run(["killall", "-9", "ffmpeg", "yt-dlp", "streamlink"], stderr=subprocess.DEVNULL)
144
- except:
145
  pass
146
- time.sleep(2)
147
-
148
- if os.path.exists(LOCK_FILE):
149
- try:
150
- with open(LOCK_FILE) as f:
151
- os.kill(int(f.read().strip()), 0)
152
- log("🔒 محرك آخر يعمل")
153
- return
154
- except:
155
- os.remove(LOCK_FILE)
156
-
157
- with open(LOCK_FILE, "w") as f:
158
- f.write(str(os.getpid()))
159
-
160
- is_streaming = False
161
- ffmpeg_proc = None
162
- was_streaming = False
163
- consecutive_fails = 0
164
- ffmpeg_restart_count = 0
165
-
166
- log("🚀 محرك النقل الشفاف (صمود محسّن) بدأ")
167
-
 
 
 
168
  while True:
 
169
  try:
170
- stream_url = get_tiktok_stream_url()
171
-
172
- if stream_url:
173
- if not was_streaming or (ffmpeg_proc and ffmpeg_proc.poll() is not None):
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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('مدير الأحداث متوقف؛ حالة الجلسات محفوظة')