Reooiq commited on
Commit
35c2217
·
1 Parent(s): a6d4cb1

Replace app entirely with Reoo88/Tktktk (TikTok->YouTube relay manager)

Browse files

- wipe previous viewer; adopt app.py/relay.py/youtube_manager.py/
flv_bridge.py/tiktok_auth.py/index.html + verify tooling
- Dockerfile: PASSWORD=admin default so the Space boots with
admin/admin Basic auth (app refuses to start without PASSWORD)
- local boot verified: 401 without/with wrong creds, 200 for admin:admin

Files changed (18) hide show
  1. .gitignore +2 -0
  2. Dockerfile +61 -5
  3. README.md +78 -8
  4. app.py +550 -0
  5. config.json +0 -8
  6. cookies.json +0 -15
  7. flv_bridge.py +82 -0
  8. index.html +108 -0
  9. relay.py +191 -0
  10. requirements.txt +3 -4
  11. tiktok_auth.py +476 -0
  12. verify.sh +139 -0
  13. verify_tokens.py +152 -0
  14. viewer.py +0 -256
  15. web.py +0 -605
  16. xbogus.py +0 -180
  17. youtube_api.py +143 -0
  18. youtube_manager.py +605 -0
.gitignore CHANGED
@@ -1 +1,3 @@
 
 
1
  __pycache__/
 
1
+ storage/
2
+ app.log
3
  __pycache__/
Dockerfile CHANGED
@@ -1,8 +1,64 @@
1
- FROM python:3.11-slim
 
 
2
  WORKDIR /app
3
- RUN apt-get update && apt-get install -y --no-install-recommends xvfb && rm -rf /var/lib/apt/lists/*
4
  COPY requirements.txt .
5
- RUN pip install --no-cache-dir -r requirements.txt && python3 -m playwright install chromium && python3 -m playwright install-deps
6
- COPY . .
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  EXPOSE 7860
8
- CMD ["xvfb-run", "--auto-servernum", "--server-args=-screen 0 1280x720x24", "python", "viewer.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, and YOUTUBE_FORMAT_GUARD=off keeps the RTMPS session alive
16
+ # across it. Restarting the session was itself a visible gap and, when
17
+ # the phone flipped resolution repeatedly, a reconnect storm.
18
+ # fixed1080 = constant 1080x1920 canvas, re-encodes to H.264. Zero gaps but costs
19
+ # CPU: measured 2.26x real time on 2 vCPU (~44% for one stream).
20
+ # fixed720 = constant 720x1280 canvas, 3.72x real time (~27%). Lighter.
21
+ # Both canvas modes stay available from Settings if YouTube's own healthStatus ever
22
+ # reports videoResolutionUnsupported; they are opt-in because of the CPU cost.
23
+ #
24
+ # YOUTUBE_FORMAT_GUARD=on -> tear the session down on a resolution change (old
25
+ # behaviour: 1-3 s gap, but YouTube re-declares the new size explicitly).
26
+ # YOUTUBE_FLAP_LIMIT: if resolution flips this many times in 10 minutes, copy mode
27
+ # falls back to YOUTUBE_FLAP_FALLBACK_MODE for the rest of the event. Dormant
28
+ # while the guard is off, kept as a safety net.
29
+ #
30
+ # YOUTUBE_FIFO_TIMESHIFT: the push goes through FFmpeg's fifo pseudo-muxer, which
31
+ # reconnects on its own when YouTube drops the connection instead of killing the
32
+ # whole process (that is why the recording kept running while the broadcast died).
33
+ # timeshift additionally delays the YouTube feed behind the live TikTok source and
34
+ # keeps that many seconds buffered, so a source hiccup is covered instead of
35
+ # starving YouTube's ingest, and packets survive a reconnect instead of being
36
+ # dropped. Cost is latency only, no CPU. https://ffmpeg.org/ffmpeg-formats.html#fifo
37
+ # NOTE: fifo's queue_size defaults to 60 packets, under one second of stream, so
38
+ # timeshift requires it to be raised or the queue overflows and the stream is
39
+ # destroyed instead of delayed. relay.py sizes it automatically (ts*250+500).
40
+ # YOUTUBE_HEALTH_INTERVAL: seconds between reads of YouTube's own
41
+ # healthStatus/configurationIssues while live. This is what reports videoIngestion
42
+ # Starved (viewers buffer), gopSizeLong, openGop, videoResolutionUnsupported and
43
+ # videoCodec, i.e. the ground truth instead of a guess.
44
+ ENV YOUTUBE_SEND_MODE=copy \
45
+ YOUTUBE_FORMAT_GUARD=off \
46
+ YOUTUBE_FLAP_LIMIT=4 \
47
+ YOUTUBE_FLAP_FALLBACK_MODE=fixed720 \
48
+ YOUTUBE_X264_PRESET=veryfast \
49
+ YOUTUBE_CRF=22 \
50
+ YOUTUBE_RECOVERY_WAIT=1 \
51
+ YOUTUBE_FIFO_TIMESHIFT=10 \
52
+ YOUTUBE_HEALTH_INTERVAL=180
53
+
54
+ # --- TikTok session token (age-restricted 18+ rooms) -------------------------
55
+ # Set TIKTOK_SESSIONID in Settings > Variables and secrets, or paste it into the
56
+ # UI panel (no Factory reboot needed). Sessions are short-lived (~6 h per
57
+ # sid_guard), so the UI panel is the practical way to refresh.
58
+ # Per account: TIKTOK_SESSIONID_<USER> (upper-case, non A-Z0-9 -> _)
59
+ # Compact map: TIKTOK_TOKENS=user1=token1;user2=token2
60
+ ENV TIKTOK_TT_TARGET_IDC=useast1a
61
+
62
+ ENV PORT=7860 PYTHONUNBUFFERED=1 YOUTUBE_VIDEO_MODE=original PASSWORD=admin
63
  EXPOSE 7860
64
+ CMD ["sh", "-c", "exec uvicorn app:app --host 0.0.0.0 --port ${PORT:-7860} --timeout-graceful-shutdown 65"]
README.md CHANGED
@@ -1,12 +1,82 @@
1
  ---
2
- title: TikTok Bot v4
3
- emoji: 🎵
4
- colorFrom: pink
5
- colorTo: blue
6
  sdk: docker
7
- app_port: 7860
8
- pinned: true
 
9
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
- # TikTok Bot v4
12
- REAL likes via /webcast/room/digg/ (X-Bogus signed API)
 
1
  ---
2
+ title: '97'
 
 
 
3
  sdk: docker
4
+ emoji: 🐨
5
+ colorFrom: pink
6
+ colorTo: gray
7
  ---
8
+ # إصلاحات مطلوبة — ملخص B / C / D
9
+
10
+ ## B — عاصفة `start_sender()` ورمز 187
11
+ **المشكلة:** `tick()` / `drive_event()` كانت تستدعي `start_sender()` بلا شرط في أول الدورة، فتدفع `ffmpeg` إلى `rtmps://a.rtmps.youtube.com:443/live2` قبل أن يكون `liveBroadcast` مربوطًا وبحالة `ready/testing/live` → 7 إخفاقات 187 خلال 5 دقائق، بداية متأخرة 5:22 و ~116 ثانية ضائعة.
12
+
13
+ **الحل في `youtube_manager.py`:**
14
+ - `start_sender()` الآن تحرس نفسها:
15
+ ```python
16
+ if not ev.get('bound') or not ev.get('broadcast_id'): return
17
+ if ev.get('youtube_status') in ('complete','revoked','created'): return
18
+ ```
19
+ - `drive_event()` لم يعد يستدعيها فورًا بعد `ensure_broadcast()`. بدلًا من ذلك:
20
+ 1. بعد `ensure_broadcast()` نحدث `ev` ونَمسح الملاحظة العالقة ثم نسقط إلى **بوابة مشروطة**
21
+ 2. نقرأ `lifeCycleStatus` الحقيقي من `liveBroadcasts` (مع احترام كوتا 45 ث)
22
+ 3. نسمح بالإرسال فقط إذا كانت `life in ('ready','testing','live')`، وإلا نُوقف المرسل ونكتب `انتظار جاهزية الاستقبال`
23
+ 4. `life == 'live'` → نشغّل المرسل ثم `stream_health`، `ready/testing + streamStatus==active` → نطلب `transition` إلى `live`
24
+
25
+ النتيجة: لا دفع قبل وجود الـ ingest، لا عاصفة 187، ولا تأخير 5 دقائق.
26
+
27
+ ---
28
+
29
+ ## C — `youtube_api.py` بلا إعادة محاولة + الملاحظة لا تُمسح
30
+
31
+ **المشكلة 1:** `_request()` كانت تفعل `urlopen(timeout=25)` مرة واحدة. أي فشل TLS عابر → `SSLError / URLError` → تُرمى كـ `خطأ داخلي ... (SSLError)` وتكتبها `_event_error()` في `note` ولا شيء يمسحها → بقيت 40 دقيقة رغم أن البث سليم.
32
+
33
+ **الحل في `youtube_api.py`:**
34
+ - إضافة `import ssl`
35
+ - حلقة إعادة محاولة 3 مرات مع backoff أسي (1ث، 2ث، 4ث):
36
+ - `HTTP 429/500/502/503/504` → إعادة محاولة مع `1.2*(attempt+1)` ث
37
+ - `URLError, TimeoutError, OSError, ValueError, ssl.SSLError, SSLEOFError ...` → إعادة محاولة، ثم `APIError(None,'networkOrInvalidResponse')` فقط بعد نفاد المحاولات
38
+ - هذا يحول الفشل اللحظي لـ TLS من رسالة "حماية الأحداث" إلى نجاح صامت بعد ثانية.
39
+
40
+ **المشكلة 2:** `_event_error()` تكتب `note` ولا مسح عند التعافي.
41
+
42
+ **الحل في `youtube_manager.py`:**
43
+ - دالة جديدة `_clear_stale_error_note(key)` تمسح `note` إذا احتوت `حماية الأحداث` أو `خطأ داخلي` أو `networkOrInvalidResponse`
44
+ - تُستدعى في `drive_event()` في 4 نقاط تعافي:
45
+ 1. بداية الدورة بعد تأكد `obs status == live`
46
+ 2. بعد `ensure_stream()` ناجح
47
+ 3. بعد `ensure_broadcast()` ناجح
48
+ 4. بعد كل `remote('liveBroadcasts')` ناجح (التحقق الفعلي من الشبكة)
49
+
50
+ فإذا تعافت الشبكة، تختفي الملاحظة فورًا بدل 40 دقيقة.
51
+
52
+ ---
53
+
54
+ ## D — `Dockerfile.txt` في المستودع
55
+
56
+ **المشكلة:** رفعت `Dockerfile` باسم `Dockerfile.txt` (sha `89e0bc09` = نسختي السابقة) فتجاهله Docker وبنى من القديم بدون:
57
+ ```
58
+ YOUTUBE_FORMAT_GUARD=off
59
+ YOUTUBE_FIFO_TIMESHIFT=10
60
+ YOUTUBE_HEALTH_INTERVAL=180
61
+ ```
62
+ فبقي `timeshift=10` يعمل فقط من القيمة الافتراضية في `relay.py` (`ts = int(os.getenv('YOUTUBE_FIFO_TIMESHIFT','10'))`) ولم تكن واضحة في الـ image.
63
+
64
+ **الحل:**
65
+ - الملف المرفق `Dockerfile` هو النسخة الصحيحة (محتوى `Dockerfile.txt` السابق) — يحتوي كل متغيرات YouTube ingestion + التعليقات الكاملة.
66
+ - **احذف `Dockerfile.txt` من المستودع** (أو على الأقل لا ترفعه):
67
+ ```bash
68
+ git rm Dockerfile.txt
69
+ git add Dockerfile youtube_api.py youtube_manager.py
70
+ git commit -m "fix: gate start_sender (187 storm), retry youtube_api TLS, clear stale note, fix Dockerfile name"
71
+ git push
72
+ ```
73
+ - بعدها `docker build` سيقرأ `Dockerfile` الصحيح مباشرة، والقيم ستكون صريحة في الـ image وليس فقط default الكود.
74
+
75
+ ---
76
+
77
+ ## الملفات المرفقة
78
+ - `youtube_api.py` — مع retry
79
+ - `youtube_manager.py` — مع guard + مسح الملاحظة
80
+ - `Dockerfile` — النسخة الصحيحة (احذف Dockerfile.txt)
81
 
82
+ > كل الإصلاحات في الكود فقط كما طلبت، بلا تغيير س��وك الحالات السليمة، وباحترام كوتا YouTube (poll كل 45ث، health كل 180ث).
 
app.py ADDED
@@ -0,0 +1,550 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # MAX_ACCOUNTS=0 means unlimited. The old default of 3 blocked adding
28
+ # legitimate accounts even though the rest of the app can handle them.
29
+ _MAX_ACCOUNTS=int(os.getenv('MAX_ACCOUNTS','0'))
30
+ MAX_ACCOUNTS=_MAX_ACCOUNTS if _MAX_ACCOUNTS>0 else None
31
+ MIN_FREE_MB=int(os.getenv('MIN_FREE_MB','1024'))
32
+ # SMART CLEANUP: guarantee free space for continuous broadcast
33
+ KEEP_LAST_FILES=max(1,int(os.getenv('KEEP_LAST_FILES','2'))) # keep newest N files always
34
+ ENSURE_FREE_MB=max(MIN_FREE_MB*2,int(os.getenv('ENSURE_FREE_MB','2048'))) # target free before/after finalize
35
+ AUTO_DELETE_MKV=True # never keep .mkv if .mp4 succeeded (user request)
36
+ lock=threading.RLock(); tasks={}; events=[]; handles={}
37
+ security=HTTPBasic()
38
+ def auth(c:HTTPBasicCredentials=Depends(security)):
39
+ if not (secrets.compare_digest(c.username.encode(),b'admin') and secrets.compare_digest(c.password.encode(),PASSWORD.encode())):
40
+ raise HTTPException(401,'Unauthorized',headers={'WWW-Authenticate':'Basic'})
41
+ app=FastAPI(dependencies=[Depends(auth)],docs_url=None,redoc_url=None,openapi_url=None)
42
+ def log(user,msg):
43
+ with lock:
44
+ events.append({'time':time.strftime('%H:%M:%S'),'user':user,'message':msg})
45
+ del events[:-200]
46
+ def update(user,**kw):
47
+ with lock: tasks[user].update(kw)
48
+ def save():
49
+ with lock:
50
+ tmp=ROOT/'tasks.tmp';tmp.write_text(json.dumps({u:t['enabled'] for u,t in tasks.items()}));tmp.replace(ROOT/'tasks.json')
51
+
52
+ def smart_cleanup(reason="periodic"):
53
+ """Intelligent deletion: always keep KEEP_LAST_FILES newest, delete oldest until ENSURE_FREE_MB is free.
54
+ Never touches .partial. files (currently recording/finalizing). Deletes .mkv first if MP4 exists."""
55
+ try:
56
+ # Never delete partial files
57
+ 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']
58
+ if not all_completed:
59
+ return 0
60
+ # Sort by mtime: newest first
61
+ all_completed.sort(key=lambda p: p.stat().st_mtime, reverse=True)
62
+ # Always keep newest KEEP_LAST_FILES
63
+ keep = set(all_completed[:KEEP_LAST_FILES])
64
+ candidates = all_completed[KEEP_LAST_FILES:] # oldest first will be at end, but we iterate oldest first for deletion
65
+ candidates.sort(key=lambda p: p.stat().st_mtime) # oldest first
66
+ freed=0
67
+ deleted=[]
68
+ # Phase 1: delete .mkv files that have a corresponding .mp4 (redundant) - user requested no mkv
69
+ if AUTO_DELETE_MKV:
70
+ for p in list(candidates):
71
+ if p.suffix == '.mkv':
72
+ # if same stem has mp4, it's redundant
73
+ stem = p.stem
74
+ # stem may be like user_20260917_... - check if any mp4 with similar prefix exists in keep or candidates
75
+ # simpler: just delete all mkv older than keep
76
+ try:
77
+ sz=p.stat().st_size
78
+ p.unlink()
79
+ freed+=sz
80
+ deleted.append(p.name)
81
+ candidates.remove(p)
82
+ except OSError:
83
+ pass
84
+ # 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
85
+ for p in list(keep):
86
+ if p.suffix == '.mkv':
87
+ # if there's an mp4 with same date prefix, delete mkv even from keep? But respect KEEP_LAST_FILES: keep mp4 instead
88
+ # Find if there's a newer mp4 that covers same period - just keep it for now, but log
89
+ pass
90
+
91
+ # Phase 2: ensure free space
92
+ free_mb = shutil.disk_usage(FILES).free / (1024*1024)
93
+ # Also consider if total size of completed files > 80% of disk, proactively clean
94
+ for p in candidates:
95
+ if free_mb >= ENSURE_FREE_MB:
96
+ break
97
+ try:
98
+ sz=p.stat().st_size
99
+ p.unlink()
100
+ freed+=sz
101
+ free_mb += sz/(1024*1024)
102
+ deleted.append(p.name)
103
+ except OSError:
104
+ continue
105
+ if deleted:
106
+ log('system', f'تنظيف ذكي ({reason}): حذف {len(deleted)} ملف قديم لتوفير {round(freed/1048576,1)}MB — ضمنا {int(free_mb)}MB فراغ للبث المستمر (احتفظنا بآخر {KEEP_LAST_FILES})')
107
+ return len(deleted)
108
+ except Exception as e:
109
+ log('system', f'تعذر التنظيف الذكي: {type(e).__name__}')
110
+ return 0
111
+ # TikTok session tokens (sessionid) — required for age-restricted (18+) lives.
112
+ # Stored per account; a global fallback can be set via the TIKTOK_SESSIONID secret.
113
+ _tokens={}
114
+ def load_tokens():
115
+ global _tokens
116
+ try:_tokens=json.loads(TOKENS.read_text())
117
+ except (FileNotFoundError,ValueError,OSError):_tokens={}
118
+ # One-way migration so tokens saved by earlier versions gain expiry tracking.
119
+ for u,t in list(_tokens.items()):
120
+ try:
121
+ if t and t.strip():tiktok_auth.save(u,t)
122
+ except (ValueError,OSError):pass
123
+ def save_tokens():
124
+ with lock:
125
+ tmp=ROOT/'tokens.tmp';tmp.write_text(json.dumps(_tokens));tmp.replace(TOKENS)
126
+ def set_token(user,token):
127
+ token=(token or '').strip()
128
+ with lock:
129
+ if token:
130
+ _tokens[user]=token
131
+ try:tiktok_auth.save(user,token)
132
+ except ValueError:pass # keep the raw value; the UI reports format problems separately
133
+ else:
134
+ _tokens.pop(user,None)
135
+ tiktok_auth.delete(user)
136
+ save_tokens()
137
+ def get_token(user):
138
+ with lock:return _tokens.get(user) or os.getenv('TIKTOK_SESSIONID','').strip()
139
+ def tiktok_headers(user):
140
+ # Accepts a bare sessionid value or a full cookie string. Never logged or echoed back.
141
+ # tiktok_auth expands it to the cookie set TikTok's own web client sends for
142
+ # age-restricted rooms: sessionid + sessionid_ss + sid_tt + tt-target-idc.
143
+ tok=get_token(user) if user else ''
144
+ if not tok:return {}
145
+ sessionid,pairs=tiktok_auth.parse_token(tok)
146
+ if not sessionid:return {}
147
+ return {'Cookie':'; '.join('%s=%s'%(k,v) for k,v in pairs.items())}
148
+ def clean_user(raw):
149
+ raw=raw.strip()
150
+ if raw.startswith('https://'):
151
+ p=urlparse(raw)
152
+ if p.hostname not in ('www.tiktok.com','tiktok.com','vm.tiktok.com','vt.tiktok.com'):raise ValueError('الرابط يجب أن يكون من TikTok')
153
+ # Only direct profile/live URLs: avoids following arbitrary redirects server-side.
154
+ m=re.fullmatch(r'/@([\w.]{1,32})(?:/live)?/?',p.path)
155
+ if not m:raise ValueError('استخدم اسم الحساب أو رابطه الكامل، وليس رابط المشاركة المختصر')
156
+ raw=m.group(1)
157
+ raw=raw.lstrip('@')
158
+ if not re.fullmatch(r'[A-Za-z0-9_.]{1,32}',raw):raise ValueError('اسم حساب غير صالح')
159
+ return raw.lower()
160
+ def get_json(url,params,user=None):
161
+ r=requests.get(url,params=params,impersonate='chrome',headers={'Referer':'https://www.tiktok.com/',**tiktok_headers(user)},timeout=20)
162
+ r.raise_for_status();return r.json()
163
+ _observation_cache={}
164
+ _observation_locks={}
165
+ _observation_guard=threading.Lock()
166
+ def observe_session(user):
167
+ with _observation_guard:guard=_observation_locks.setdefault(user,threading.Lock())
168
+ with guard:
169
+ cached=_observation_cache.get(user)
170
+ if cached and time.monotonic()-cached[0]<15:return cached[1]
171
+ result=_observe_session(user)
172
+ _observation_cache[user]=(time.monotonic(),result)
173
+ return result
174
+
175
+ def _observe_session(user):
176
+ j=get_json('https://www.tiktok.com/api-live/user/room',{'aid':1988,'uniqueId':user,'sourceType':54,'staleTime':0},user)
177
+ d=j.get('data',{});live=d.get('liveRoom',{});status=live.get('status');person=d.get('user',{})
178
+ room=str(person.get('roomId') or person.get('room_id') or '')
179
+ nickname=str(person.get('nickname') or '').strip()
180
+ live_title=str(live.get('title') or '').strip()
181
+ if status in (4,'4'):return {'status':'offline','room_id':room,'sources':[],'nickname':nickname,'live_title':live_title}
182
+ blocks=[live[k] for k in ('streamData','hevcStreamData') if isinstance(live.get(k),dict)]
183
+ sources=rank_sources({'blocks':blocks}) if status in (2,'2') else []
184
+ # Live but every stream block is empty: the classic age-restricted (18+) shape.
185
+ if status in (2,'2') and not sources and tiktok_auth.classify_restriction(j):
186
+ raise RuntimeError(MSG)
187
+ if not room or room=='0':
188
+ r=requests.get(f'https://www.tiktok.com/@{user}/live',impersonate='chrome',headers={'Referer':'https://www.tiktok.com/',**tiktok_headers(user)},timeout=20)
189
+ r.raise_for_status();m=re.search(r'"roomId"\s*:\s*"(\d+)"',r.text)
190
+ room=m.group(1) if m else ''
191
+ if not room.isdigit() or room=='0':raise RuntimeError('تعذر تحديد معرّف جلسة البث؛ لن ننشئ حدث يوتيوب بالتخمين')
192
+ if not sources:
193
+ j=get_json('https://webcast.tiktok.com/webcast/room/info/',{'aid':1988,'room_id':room},user)
194
+ info=j.get('data',{});status=info.get('status');owner=info.get('owner') or {}
195
+ nickname=str(owner.get('nickname') or '').strip() or nickname
196
+ live_title=str(info.get('title') or '').strip() or live_title
197
+ if status in (4,'4'):return {'status':'offline','room_id':room,'sources':[],'nickname':nickname,'live_title':live_title}
198
+ if status not in (2,'2'):
199
+ if tiktok_auth.classify_restriction(j):raise RuntimeError(MSG)
200
+ raise RuntimeError('حالة تيك توك غير مؤكدة؛ لا نعتبرها انتهاء للبث')
201
+ sources=rank_sources(info.get('stream_url') or {})
202
+ if not sources and tiktok_auth.classify_restriction(j):raise RuntimeError(MSG)
203
+ return {'status':'live','room_id':room,'sources':sources,'nickname':nickname,'live_title':live_title}
204
+
205
+ def extract(user):
206
+ observed=observe_session(user)
207
+ if observed['status']=='offline':return []
208
+ if not observed['sources']:raise RuntimeError('البث مباشر لكن روابط الفيديو غير متاحة — '+MSG)
209
+ return observed['sources']
210
+
211
+ def rank_sources(source):
212
+ candidates=[]
213
+ 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}
214
+ def add(url,label,params=None,assumed=False):
215
+ if not isinstance(url,str) or not url.startswith('https://') or '.m3u8' in url:return
216
+ if str(label).lower() in ('ao','audio','audio_only'):return
217
+ params=params or {};res=str(params.get('resolution',''))
218
+ nums=re.findall(r'\d+',res)
219
+ pixels=int(nums[0])*int(nums[1]) if len(nums)>1 else (int(nums[0])**2 if nums else 0)
220
+ try:rate=float(params.get('vbitrate') or 0)
221
+ except (ValueError,TypeError):rate=0
222
+ quality=priorities.get(str(label).lower(),50)
223
+ # Explicit original labels first; use declared dimensions/rate when supplied.
224
+ score=(quality==100, pixels,rate,quality,not assumed)
225
+ candidates.append({'url':url,'label':str(label)+((' · '+res) if res else '')+' · '+str(params.get('VCodec') or params.get('v_codec') or 'غير محدد'),'score':score})
226
+ blocks=list(source.get('blocks',[]))
227
+ if source.get('live_core_sdk_data'):blocks.append(source['live_core_sdk_data'])
228
+ for block in blocks:
229
+ try:
230
+ payload=block['pull_data']['stream_data']
231
+ stream=json.loads(payload) if isinstance(payload,str) else payload
232
+ for quality,item in stream.get('data',{}).items():
233
+ main=item.get('main',{})
234
+ try:
235
+ params=main.get('sdk_params') or {}
236
+ if isinstance(params,str):params=json.loads(params)
237
+ except (ValueError,TypeError):params={}
238
+ add(main.get('flv'),quality,params)
239
+ except (KeyError,TypeError,ValueError):pass
240
+ for quality,url in (source.get('flv_pull_url') or {}).items():
241
+ try:params=json.loads((source.get('flv_pull_url_params') or {}).get(quality) or '{}')
242
+ except (ValueError,TypeError):params={}
243
+ add(url,quality,params)
244
+ # Do not assume the generic pull URL is the original feed.
245
+ add(source.get('rtmp_pull_url'),'غير مصنفة',assumed=True)
246
+ result=[];seen=set()
247
+ for item in sorted(candidates,key=lambda c:c['score'],reverse=True):
248
+ if item['url'] not in seen:result.append(item);seen.add(item['url'])
249
+ return result
250
+
251
+ from youtube_manager import ManagedYouTube
252
+ def enabled_accounts():
253
+ with lock:return [u for u,t in tasks.items() if t.get('enabled')]
254
+ youtube=ManagedYouTube(FFMPEG,observe_session,log,enabled_accounts,ROOT/'youtube-api-state.json',headers_for=tiktok_headers)
255
+
256
+ class FLVWriter:
257
+ """Write complete FLV tags only; arbitrary network chunk boundaries are not packet boundaries."""
258
+ def __init__(self,out):self.out=out;self.buf=bytearray();self.header=False;self.written=0
259
+ def feed(self,chunk):
260
+ self.buf.extend(chunk)
261
+ if not self.header:
262
+ if len(self.buf)<9:return
263
+ if self.buf[:3]!=b'FLV':raise RuntimeError('المصدر لا يعيد FLV صالحًا')
264
+ size=int.from_bytes(self.buf[5:9],'big')+4
265
+ if not 13<=size<=1048576:raise RuntimeError('ترويسة FLV غير صالحة')
266
+ if len(self.buf)<size:return
267
+ self.out.write(self.buf[:size]);del self.buf[:size];self.written+=size;self.header=True
268
+ while len(self.buf)>=11:
269
+ size=11+int.from_bytes(self.buf[1:4],'big')+4
270
+ if len(self.buf)<size:break
271
+ self.out.write(self.buf[:size]);del self.buf[:size];self.written+=size
272
+
273
+ def inspect_media(path):
274
+ r=subprocess.run([FFPROBE,'-v','error','-show_streams','-of','json',str(path)],capture_output=True,text=True,timeout=60)
275
+ if r.returncode:raise RuntimeError('تعذر فحص بيانات الفيديو')
276
+ streams=json.loads(r.stdout).get('streams',[])
277
+ video=next((x for x in streams if x.get('codec_type')=='video' and x.get('width',0)>0),None)
278
+ if not video:raise RuntimeError('لم يوجد مسار فيديو صالح')
279
+ return video
280
+
281
+ def finalize(raw,stem,user):
282
+ mkv=FILES/(stem+'.partial.mkv');mp4=FILES/(stem+'.partial.mp4')
283
+ # SMART: ensure space before heavy remux (needs ~2x file size temporarily)
284
+ smart_cleanup(reason="قبل البناء")
285
+ # Serialize remuxes: recording threads continue, but concurrent disk copies are limited.
286
+ with FINALIZE_LOCK:
287
+ # Double-check free after waiting for lock
288
+ if shutil.disk_usage(FILES).free < ENSURE_FREE_MB*1024*1024:
289
+ smart_cleanup(reason="قبل البناء - طارئ")
290
+ r=subprocess.run([MKVMERGE,'-o',str(mkv),str(raw)],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=3600)
291
+ if r.returncode not in (0,1) or not mkv.exists():raise RuntimeError('تعذر تغليف FLV إلى MKV؛ احتفظنا بالأصل')
292
+ info=inspect_media(mkv)
293
+ codec_args=['-tag:v','hvc1'] if info.get('codec_name')=='hevc' else []
294
+ 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)
295
+ if r.returncode==0 and mp4.exists() and mp4.stat().st_size>20000:
296
+ measured=inspect_media(mp4)
297
+ mp4.replace(FILES/(stem+'.mp4'));mkv.unlink(missing_ok=True);raw.unlink(missing_ok=True)
298
+ quality=f"{measured['width']}×{measured['height']} · {measured.get('codec_name','')}"
299
+ if user in tasks:update(user,quality=quality)
300
+ log(user,'تم حفظ MP4 دون إعادة ترميز؛ قياس الملف: '+quality+' (ليس فحص فك ترميز كاملًا)')
301
+ # SMART: never keep mkv if mp4 succeeded (user request)
302
+ # Already deleted mkv above. Also ensure we keep only KEEP_LAST_FILES
303
+ smart_cleanup(reason="بعد البناء")
304
+ else:
305
+ mp4.unlink(missing_ok=True)
306
+ # If mp4 failed but mkv succeeded, keep mkv but user said no need mkv for youtube. Keep mkv only as fallback
307
+ # To respect request "ماله داعي نحفظ بث اليوتيوب الي امتداده mkv" -> delete mkv if we prefer mp4 only? Keep mkv only if mp4 failed
308
+ if AUTO_DELETE_MKV and mkv.exists() and mkv.stat().st_size < 50*1024*1024:
309
+ # small mkv with no mp4? Keep it but log
310
+ pass
311
+ mkv.replace(FILES/(stem+'.mkv'));raw.unlink(missing_ok=True)
312
+ log(user,'تعذر MP4؛ تم حفظ MKV صالح حسب فحص المسارات')
313
+ smart_cleanup(reason="بعد بناء MKV")
314
+
315
+ def record(user,candidate,stop):
316
+ # SMART: pre-check and clean before starting new segment
317
+ if shutil.disk_usage(FILES).free < ENSURE_FREE_MB*1024*1024:
318
+ smart_cleanup(reason="قبل التسجيل")
319
+ if shutil.disk_usage(FILES).free < MIN_FREE_MB*1024**2:
320
+ raise RuntimeError('مساحة نظام الملفات منخفضة — تم التنظيف الذكي وما زالت ممتلئة')
321
+ stem=f'{user}_{time.strftime("%Y%m%d_%H%M%S")}_{secrets.token_hex(2)}'
322
+ raw=FILES/(stem+'.partial.flv');start=time.monotonic();last_update=start
323
+ if user in tasks:update(user,status='جاري الاتصال',quality='مصدر: '+candidate['label'],received_mb=0,elapsed_seconds=0)
324
+ response=requests.get(candidate['url'],impersonate='chrome',headers={'Referer':'https://www.tiktok.com/',**tiktok_headers(user)},stream=True,timeout=20)
325
+ try:
326
+ response.raise_for_status()
327
+ with open(raw,'wb') as out:
328
+ writer=FLVWriter(out)
329
+ try:
330
+ for chunk in response.iter_content():
331
+ if stop.is_set() or (SEGMENT and time.monotonic()-start>=SEGMENT):break
332
+ if chunk:writer.feed(chunk)
333
+ if time.monotonic()-last_update>=2:
334
+ out.flush();last_update=time.monotonic()
335
+ if user in tasks:update(user,status='تسجيل — وصول بيانات',received_mb=round(writer.written/1048576,1),elapsed_seconds=int(last_update-start))
336
+ if shutil.disk_usage(FILES).free<MIN_FREE_MB*1024**2:
337
+ log(user,'انخفاض المساحة؛ إنهاء الملف');break
338
+ except Exception as e:
339
+ log(user,'انقطع استقبال المصدر؛ محاولة حفظ البيانات المكتملة ('+type(e).__name__+')')
340
+ out.flush()
341
+ finally:response.close()
342
+ if not raw.exists() or raw.stat().st_size<20000:
343
+ raw.unlink(missing_ok=True);raise RuntimeError('لم تصل بيانات فيديو كافية')
344
+ if user in tasks:update(user,status='تجهيز الملف — قد يستغرق وقتًا للبث الطويل')
345
+ try:finalize(raw,stem,user)
346
+ except Exception:
347
+ # Keep the complete-tag raw recording for manual recovery, never silently delete it.
348
+ if raw.exists():raw.replace(FILES/(stem+'.recovery.flv'))
349
+ log(user,'فشل التجهيز؛ احتفظنا بملف recovery.flv للتنزيل والاستعادة')
350
+ raise RuntimeError('فشل تجهيز المصدر؛ سيجرب مصدرًا آخر')
351
+
352
+ def worker(user,stop):
353
+ failures=0
354
+ while not stop.is_set():
355
+ # periodic smart check every loop
356
+ if failures==0:
357
+ # don't spam cleanup every 30s, check free occasionally
358
+ if shutil.disk_usage(FILES).free < ENSURE_FREE_MB*1024*1024:
359
+ smart_cleanup(reason="دوري")
360
+ try:
361
+ update(user,status='فحص البث')
362
+ urls=extract(user)
363
+ if not urls:
364
+ update(user,status='البث منتهٍ');stop.wait(INTERVAL);continue
365
+ update(user,status='جاري الاتصال بمصدر الفيديو');ok=False
366
+ for url in urls:
367
+ if stop.is_set():break
368
+ try:record(user,url,stop);ok=True;break
369
+ except Exception:log(user,'فشل مصدر الفيديو؛ تجربة مصدر آخر إن توفر')
370
+ if not ok and not stop.is_set():raise RuntimeError('فشلت مصادر الفيديو؛ إعادة المحاولة لاحقًا')
371
+ failures=0
372
+ if not stop.is_set():stop.wait(2)
373
+ except Exception as e:
374
+ failures+=1
375
+ # Never display exception URLs/tokens.
376
+ msg=str(e) if isinstance(e,RuntimeError) else 'فشل طلب بيانات البث أو التسجيل (ليس تأكيدًا أن الحساب غير مباشر)'
377
+ update(user,status='انتظار إعادة المحاولة');log(user,msg)
378
+ stop.wait(min(300,INTERVAL*failures))
379
+ update(user,status='متوقف')
380
+ def start(user):
381
+ with lock:
382
+ if user in handles and handles[user]['thread'].is_alive():return
383
+ stop=threading.Event();thread=threading.Thread(target=worker,args=(user,stop),daemon=True)
384
+ handles[user]={'stop':stop,'thread':thread,'process':None};thread.start()
385
+ class Account(BaseModel):
386
+ username:str
387
+ token:str=''
388
+ class TokenBody(BaseModel):
389
+ token:str=''
390
+ @app.get('/',response_class=HTMLResponse)
391
+ def index():return Path(__file__).with_name('index.html').read_text()
392
+ class YouTubeStart(BaseModel):
393
+ channel_id:str
394
+ class YouTubeSettings(BaseModel):
395
+ name:str=''
396
+ privacy:str
397
+ title:str
398
+ description:str
399
+ @app.get('/api/youtube')
400
+ def youtube_state():return youtube.snapshot()
401
+ @app.post('/api/youtube/check')
402
+ def youtube_check():
403
+ try:return {'channel':youtube.check()}
404
+ except Exception as e:
405
+ from youtube_api import APIError
406
+ msg=str(e) if isinstance(e,(APIError,RuntimeError,ValueError)) else 'تعذر فحص تفويض يوتيوب'
407
+ raise HTTPException(400,msg)
408
+ @app.post('/api/youtube/settings')
409
+ def youtube_settings(a:YouTubeSettings):
410
+ try:youtube.settings(a.model_dump())
411
+ except ValueError as e:raise HTTPException(400,str(e))
412
+ return {'ok':True}
413
+ @app.post('/api/youtube/start')
414
+ def youtube_start(a:YouTubeStart):
415
+ try:youtube.enable(a.channel_id)
416
+ except Exception as e:
417
+ from youtube_api import APIError
418
+ msg=str(e) if isinstance(e,(APIError,RuntimeError,ValueError)) else 'تعذر بدء أتمتة يوتيوب'
419
+ raise HTTPException(400,msg)
420
+ return {'ok':True}
421
+ @app.post('/api/youtube/stop')
422
+ def youtube_stop():
423
+ youtube.disable();return {'ok':True}
424
+
425
+ @app.get('/api/state')
426
+ def state():
427
+ with lock:
428
+ disk=shutil.disk_usage(FILES)
429
+ # trigger smart view but don't delete here - just report
430
+ 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]
431
+ task_list=[{'username':u,**t,'has_token':bool(get_token(u))} for u,t in tasks.items()]
432
+ tiktok_state=tiktok_auth.describe([t['username'] for t in task_list])
433
+ free_mb = round(disk.free/1048576,1)
434
+ 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}
435
+ @app.post('/api/accounts')
436
+ def add(a:Account):
437
+ try:u=clean_user(a.username)
438
+ except ValueError as e:raise HTTPException(400,str(e))
439
+ with lock:
440
+ if MAX_ACCOUNTS is not None and u not in tasks and len(tasks)>=MAX_ACCOUNTS:
441
+ raise HTTPException(400,'بلغت حد الموارد المحدد؛ يمكنك تغييره عبر MAX_ACCOUNTS')
442
+ tasks.setdefault(u,{'enabled':True,'status':'بدء التشغيل'});tasks[u]['enabled']=True;save();start(u)
443
+ if a.token and a.token.strip():set_token(u,a.token)
444
+ return {'ok':True}
445
+ @app.post('/api/accounts/{user}/token')
446
+ def account_token(user:str,b:TokenBody):
447
+ with lock:
448
+ if user not in tasks:raise HTTPException(404)
449
+ set_token(user,b.token)
450
+ return {'ok':True,'has_token':bool(get_token(user)),'tiktok':tiktok_auth.describe([user])}
451
+
452
+ @app.get('/api/tiktok/token')
453
+ def tiktok_token_state():
454
+ """Masked token state only — never the token itself."""
455
+ with lock:users=list(tasks.keys())
456
+ return tiktok_auth.describe(users)
457
+
458
+ @app.post('/api/tiktok/token/validate')
459
+ def tiktok_token_validate(b:TokenBody):
460
+ """Ask TikTok whether the pasted token is alive. A rejected token is not saved."""
461
+ raw=(b.token or '').strip()
462
+ if not raw:raise HTTPException(400,'الصق التوكن أولًا')
463
+ report=tiktok_auth.validate(raw)
464
+ if report.get('ok'):
465
+ # Only a token TikTok accepted reaches the store.
466
+ try:tiktok_auth.save('',raw)
467
+ except ValueError as e:raise HTTPException(400,str(e))
468
+ log('system','حُفظ توكن تيك توك %s — سيُستخدم للرصد والتنزيل'%report.get('sessionid_masked',''))
469
+ else:
470
+ log('system','تيك توك رفض التوكن؛ لم يُحفظ')
471
+ return report
472
+
473
+ @app.post('/api/tiktok/token/check/{user}')
474
+ def tiktok_token_check(user:str):
475
+ """Live proof: does this account's room yield video URLs with vs without the token?"""
476
+ try:return tiktok_auth.unlock_check(user)
477
+ except Exception as e:
478
+ raise HTTPException(400,str(e) if isinstance(e,(ValueError,RuntimeError)) else 'تعذر اختبار فتح الحساب')
479
+
480
+ @app.delete('/api/tiktok/token/{user}')
481
+ def tiktok_token_delete(user:str):
482
+ with lock:
483
+ _tokens.pop(user,None);save_tokens()
484
+ return {'ok':tiktok_auth.delete(user)}
485
+ @app.post('/api/accounts/{user}/{action}')
486
+ def action(user:str,action:str):
487
+ with lock:
488
+ if user not in tasks:raise HTTPException(404)
489
+ if action=='start':
490
+ if user in handles and handles[user]['thread'].is_alive() and handles[user]['stop'].is_set():raise HTTPException(409,'انتظر اكتمال إيقاف المقطع')
491
+ tasks[user]['enabled']=True;start(user)
492
+ elif action=='stop':
493
+ tasks[user]['enabled']=False
494
+ if user in handles:handles[user]['stop'].set()
495
+ elif action=='delete':
496
+ if user in handles and handles[user]['thread'].is_alive():raise HTTPException(409,'أوقف الحساب وانتظر انتهاء الحفظ قبل حذفه')
497
+ del tasks[user]
498
+ _tokens.pop(user,None);save_tokens()
499
+ else:raise HTTPException(400)
500
+ save()
501
+ return {'ok':True}
502
+ def safe_file(name):
503
+ p=(FILES/name).resolve()
504
+ 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)
505
+ return p
506
+ @app.get('/api/files/{name}')
507
+ def download(name:str):return FileResponse(safe_file(name),filename=name)
508
+ @app.delete('/api/files/{name}')
509
+ def delete(name:str):safe_file(name).unlink();return {'ok':True}
510
+ @app.on_event('startup')
511
+ def startup():
512
+ load_tokens()
513
+ # smart cleanup on startup to guarantee space
514
+ try:
515
+ smart_cleanup(reason="بدء التشغيل")
516
+ except Exception:
517
+ pass
518
+ try:saved=json.loads((ROOT/'tasks.json').read_text())
519
+ except FileNotFoundError:saved={}
520
+ except (OSError,ValueError):
521
+ logging.exception('Unable to restore task settings')
522
+ log('system','تعذر قراءة الحسابات المحفوظة؛ تحقق من التخزين قبل إضافة حسابات')
523
+ saved={}
524
+ if not isinstance(saved,dict):
525
+ log('system','صيغة ملف الحسابات غير صالحة');saved={}
526
+ restored=list(saved.items())
527
+ if MAX_ACCOUNTS is not None:
528
+ restored=restored[:MAX_ACCOUNTS]
529
+ for u,enabled in restored:
530
+ try:u=clean_user(u)
531
+ except ValueError:continue
532
+ tasks[u]={'enabled':bool(enabled),'status':'متوقف'}
533
+ if enabled:start(u)
534
+ youtube.restore()
535
+ @app.on_event('shutdown')
536
+ def shutdown():
537
+ youtube.stop()
538
+ for h in list(handles.values()):h['stop'].set()
539
+ deadline=time.monotonic()+50
540
+ for h in list(handles.values()):h['thread'].join(timeout=max(0,deadline-time.monotonic()))
541
+
542
+ # periodic smart janitor every 10 minutes
543
+ def janitor_loop():
544
+ while True:
545
+ time.sleep(600)
546
+ try:
547
+ smart_cleanup(reason="دوري كل 10 دقايق")
548
+ except Exception:
549
+ pass
550
+ threading.Thread(target=janitor_loop, daemon=True).start()
config.json DELETED
@@ -1,8 +0,0 @@
1
- {
2
- "target_url": "https://www.tiktok.com/@u.9.1.5/live",
3
- "like_interval": 0.3,
4
- "like_count": 50,
5
- "comment_interval": 30,
6
- "accounts": [],
7
- "comments": ["❤️","🔥","👏","💯","🎉","hi","hello","nice"]
8
- }
 
 
 
 
 
 
 
 
 
cookies.json DELETED
@@ -1,15 +0,0 @@
1
- [
2
- {"name": "sessionid", "value": "41aae95f6250e392d346517b808b123d", "domain": ".tiktok.com", "path": "/"},
3
- {"name": "uid_tt", "value": "b7c572318be7bcea471b2c1418f40f681a2c16dbbd15197f5832caa83462654f", "domain": ".tiktok.com", "path": "/"},
4
- {"name": "sid_tt", "value": "41aae95f6250e392d346517b808b123d", "domain": ".tiktok.com", "path": "/"},
5
- {"name": "tt-target-idc", "value": "alisg", "domain": ".tiktok.com", "path": "/"},
6
- {"name": "tt_csrf_token", "value": "mLyVbVuj-xnigesLThZVb1kbI_aqbzf11Z00", "domain": ".tiktok.com", "path": "/"},
7
- {"name": "odin_tt", "value": "f5681245be037f1c4784dfdc4df2cbccf88626c5d3f80204410b4da6e3f2c28be3739b76ffe9ea5d4da543e7a53ce98222849eecd985b8eaa1bd9b1cc6c361c68fa5f9ad1f5b4a74a4724b761e825412", "domain": ".tiktok.com", "path": "/"},
8
- {"name": "sid_guard", "value": "41aae95f6250e392d346517b808b123d", "domain": ".tiktok.com", "path": "/"},
9
- {"name": "s_v_web_id", "value": "verify_mu6qjf6l_bG48XIKQ_zrOT_4o2e_8E6F_914uFkQb8jGE", "domain": ".tiktok.com", "path": "/"},
10
- {"name": "d_ticket", "value": "4bbb736afaba261a60580773eb3cef4f7d1a5", "domain": ".tiktok.com", "path": "/"},
11
- {"name": "tt_chain_token", "value": "AJ1ZFXstqZ1R8J5VqV+knQ==", "domain": ".tiktok.com", "path": "/"},
12
- {"name": "multi_sids", "value": "7516614217303508024%3A41aae95f6250e392d346517b808b123d", "domain": ".tiktok.com", "path": "/"},
13
- {"name": "living_user_id", "value": "636078685038", "domain": ".tiktok.com", "path": "/"},
14
- {"name": "store-country-code", "value": "br", "domain": ".tiktok.com", "path": "/"}
15
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
flv_bridge.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Streaming legacy HEVC FLV (codec-id 12) -> Enhanced FLV hvc1.
2
+ Only tag headers/container fields are changed. Encoded video payload, CTS and DTS
3
+ are preserved. H.264 and already-enhanced FLV tags pass through unchanged.
4
+ No whole-recording buffering, decoding, rescaling or frame-rate conversion.
5
+
6
+ Extra guard for YouTube ingestion:
7
+ FormatChanged is raised the moment a *different* video decoder configuration
8
+ record (AVCDecoderConfigurationRecord / HEVCDecoderConfigurationRecord, i.e.
9
+ SPS/PPS or VPS/SPS/PPS) shows up in the middle of a session. That is exactly
10
+ what a phone does when the layout changes (solo <-> PK round) or when the
11
+ encoder restarts after the app was backgrounded and resumed.
12
+
13
+ Why it matters: `ffmpeg -c:v copy` cannot detect an input parameter change, so
14
+ it silently forwards the new frames while the FLV it already sent to YouTube
15
+ declares the old width/height in onMetaData and in the first sequence header.
16
+ YouTube then stretches/re-locks the picture -> fast zoom in-out, stutter,
17
+ a long "repeated clip", freeze, black flash. The caller restarts the sender on
18
+ this exception; the fresh RTMPS session re-declares the new size cleanly
19
+ (a ~1-3 s gap instead of minutes of broken video).
20
+
21
+ This is pure byte inspection of tag headers: no decoding, no re-encoding, no
22
+ rescaling, so the original picture quality and the original source are untouched.
23
+ """
24
+ class FormatChanged(Exception):
25
+ """Source video decoder configuration changed mid-stream."""
26
+
27
+ def _video_signature(body):
28
+ """Fingerprint of a video sequence header, or None if `body` is not one."""
29
+ if not body:return None
30
+ if body[0]&0x80: # Enhanced RTMP
31
+ if body[0]&0x0F:return None # PacketType 0 == sequence header
32
+ return bytes(body[1:5])+body[5:] # FourCC + decoder config record
33
+ if (body[0]&15)==7 and len(body)>1 and body[1]==0: # legacy AVC sequence header
34
+ return b'avc1'+body[5:]
35
+ return None
36
+
37
+ class FLVBridge:
38
+ def __init__(self,detect_format_change=True):
39
+ self.buffer=bytearray();self.header_done=False;self.converted_tags=0
40
+ self.detect_format_change=detect_format_change
41
+ self.video_cfg=None;self.format_changes=0
42
+ def feed(self,chunk):
43
+ self.buffer.extend(chunk);output=bytearray()
44
+ if not self.header_done:
45
+ if len(self.buffer)<9:return b''
46
+ if self.buffer[:3]!=b'FLV':raise ValueError('Source is not FLV')
47
+ header_size=int.from_bytes(self.buffer[5:9],'big')+4
48
+ if not 13<=header_size<=1048576:raise ValueError('Invalid FLV header size')
49
+ if len(self.buffer)<header_size:return b''
50
+ output.extend(self.buffer[:header_size]);del self.buffer[:header_size];self.header_done=True
51
+ offset=0
52
+ while len(self.buffer)-offset>=11:
53
+ header=bytearray(self.buffer[offset:offset+11]);size=int.from_bytes(header[1:4],'big');total=11+size+4
54
+ if len(self.buffer)-offset<total:break
55
+ body=bytes(self.buffer[offset+11:offset+11+size]);tag_type=header[0]
56
+ if tag_type & 0x20:raise ValueError('Filtered/encrypted FLV tag is unsupported')
57
+ if (tag_type & 31)==9 and body and not (body[0]&0x80) and (body[0]&15)==12:
58
+ if len(body)<5:raise ValueError('Truncated HEVC FLV video header')
59
+ frame=(body[0]>>4)&7;packet=body[1]
60
+ if packet==0: # HEVC configuration record, no composition time in SequenceStart
61
+ body=bytes([0x80|(frame<<4)])+b'hvc1'+body[5:]
62
+ elif packet==1: # CodedFrames includes the same signed 24-bit composition offset
63
+ body=bytes([0x80|(frame<<4)|1])+b'hvc1'+body[2:]
64
+ elif packet==2:
65
+ body=bytes([0x80|(frame<<4)|2])+b'hvc1'+body[5:]
66
+ else:raise ValueError('Unsupported legacy HEVC packet type')
67
+ self.converted_tags+=1
68
+ if (tag_type & 31)==9:
69
+ signature=_video_signature(body)
70
+ if signature is not None:
71
+ if self.video_cfg is None:self.video_cfg=signature
72
+ elif signature!=self.video_cfg:
73
+ self.format_changes+=1
74
+ if self.detect_format_change:
75
+ raise FormatChanged('video decoder configuration changed mid-stream')
76
+ self.video_cfg=signature
77
+ header[1:4]=len(body).to_bytes(3,'big')
78
+ output.extend(header);output.extend(body);output.extend((11+len(body)).to_bytes(4,'big'))
79
+ offset+=total
80
+ if offset:del self.buffer[:offset]
81
+ if len(self.buffer)>17*1024*1024:raise ValueError('FLV packet buffer exceeds limit')
82
+ return bytes(output)
index.html 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,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One FFmpeg push per live session. Multiple senders run concurrently, one per YouTube event."""
2
+ import os, time, threading, subprocess
3
+ from collections import deque
4
+ from curl_cffi import requests
5
+ from flv_bridge import FLVBridge, FormatChanged
6
+
7
+ class Sender:
8
+ """Single YouTube RTMPS push for one session. Process and progress are instance-scoped,
9
+ so several senders can run at the same time without sharing state."""
10
+ def __init__(self,ffmpeg,log,headers_for=None):
11
+ self.ffmpeg=ffmpeg;self.log=log
12
+ self.headers_for=headers_for or (lambda user:{})
13
+ self.lock=threading.RLock();self.process=None
14
+ self.data={'status':'','source':'','output_seconds':0,'last_output_at':None}
15
+ # Timestamps of source resolution changes seen by this sender (anti-flap window).
16
+ self.flap=deque()
17
+ # Once the source proved to flap, stay on a constant canvas for the rest of the
18
+ # event. Without this the flap window expires after 10 quiet minutes and the
19
+ # sender silently drops back to copy mode, restarting the whole failure loop.
20
+ self.sticky_mode=None
21
+ def set(self,**kw):
22
+ with self.lock:self.data.update(kw)
23
+ def snapshot(self):
24
+ with self.lock:return dict(self.data)
25
+ def terminate(self):
26
+ with self.lock:p=self.process
27
+ if p and p.poll() is None:
28
+ try:p.terminate()
29
+ except OSError:pass
30
+ def send_mode(self):
31
+ """copy -> forward the source untouched (original quality, no extra CPU)
32
+ fixed1080 -> re-encode onto a constant 1080x1920 canvas
33
+ fixed720 -> re-encode onto a constant 720x1280 canvas (lighter on CPU)
34
+ """
35
+ mode=os.getenv('YOUTUBE_SEND_MODE','copy').strip().lower()
36
+ if mode not in ('copy','fixed1080','fixed720'):
37
+ raise ValueError('YOUTUBE_SEND_MODE يجب أن يكون copy أو fixed1080 أو fixed720')
38
+ # A source already proven to flap stays on the constant canvas for this whole event.
39
+ if self.sticky_mode:return self.sticky_mode
40
+ # copy mode only: if the phone keeps flipping resolution, stop flapping the
41
+ # YouTube session and absorb the changes on a constant canvas instead.
42
+ if mode=='copy':
43
+ flap=self.flap
44
+ now=time.monotonic()
45
+ while flap and now-flap[0]>600:flap.popleft()
46
+ if len(flap)>=int(os.getenv('YOUTUBE_FLAP_LIMIT','4')):
47
+ mode=os.getenv('YOUTUBE_FLAP_FALLBACK_MODE','fixed720').strip().lower()
48
+ if mode not in ('fixed1080','fixed720'):mode='fixed720'
49
+ self.sticky_mode=mode
50
+ return mode
51
+ def note_format_change(self):
52
+ self.flap.append(time.monotonic())
53
+ if len(self.flap)>=int(os.getenv('YOUTUBE_FLAP_LIMIT','4')):
54
+ self.log('system','تغيّرت أبعاد المصدر عدة مرات خلال 10 دقائق؛ التحول تلقائيًا إلى كانفس ثابت (%s) لمنع تكرار الانقطاع'%os.getenv('YOUTUBE_FLAP_FALLBACK_MODE','fixed720'))
55
+ @staticmethod
56
+ def canvas(width,height):
57
+ return ('scale=w=%d:h=%d:force_original_aspect_ratio=decrease:flags=lanczos,'
58
+ 'pad=%d:%d:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,format=yuv420p'%(width,height,width,height))
59
+ def command(self,destination):
60
+ # Advertise Enhanced RTMP for network output; local test files need no protocol option.
61
+ mode=self.send_mode()
62
+ network=destination.startswith(('rtmp://','rtmps://'))
63
+ enhanced=['-rtmp_enhanced_codecs','hvc1'] if network and mode=='copy' and os.getenv('YOUTUBE_VIDEO_MODE','original').strip().lower()=='original' else []
64
+ if mode=='copy':
65
+ video=['-c:v','copy']
66
+ else:
67
+ width,height,bitrate=(1080,1920,'6000k') if mode=='fixed1080' else (720,1280,'4000k')
68
+ bufsize=str(int(int(bitrate[:-1])*1.5))+'k'
69
+ # A constant canvas is the only thing that guarantees YouTube always receives
70
+ # frames whose real size matches the signalled one, whatever the phone does.
71
+ video=['-vf',self.canvas(width,height),'-c:v','libx264','-preset',os.getenv('YOUTUBE_X264_PRESET','veryfast'),
72
+ '-tune','zerolatency','-profile:v','high','-crf',os.getenv('YOUTUBE_CRF','22'),
73
+ '-maxrate',bitrate,'-bufsize',bufsize,'-g','60','-keyint_min','30','-sc_threshold','0',
74
+ '-pix_fmt','yuv420p','-fps_mode','cfr',
75
+ '-colorspace','bt709','-color_primaries','bt709','-color_trc','bt709']
76
+ # Network output goes through the fifo pseudo-muxer instead of straight to flv.
77
+ # fifo separates encoding from the RTMPS connection and reconnects transparently
78
+ # when YouTube drops it, rather than killing the whole FFmpeg process. That is
79
+ # exactly why the local recording kept running while the broadcast died: writing
80
+ # to disk never fails, a network blip does, and with -f flv it was fatal.
81
+ # Documented: https://ffmpeg.org/ffmpeg-formats.html#fifo
82
+ # recover_any_error is deliberately NOT set, so permanent faults (bad stream key,
83
+ # rejected codec) still fail fast and stay visible in the log instead of retrying
84
+ # forever in silence.
85
+ if network:
86
+ # timeshift deliberately delays the YouTube feed behind the live TikTok source
87
+ # and keeps that much data buffered. Two effects, both free of CPU cost: a
88
+ # source hiccup is covered by the buffer instead of starving YouTube's ingest
89
+ # (which it reports as videoIngestionStarved = viewer buffering), and packets
90
+ # are retained across a reconnect instead of being dropped, which is what made
91
+ # segments go missing when the streamer restarted. Cost is latency only.
92
+ ts=int(os.getenv('YOUTUBE_FIFO_TIMESHIFT','10'))
93
+ # queue_size MUST grow with timeshift. Its default is 60 packets, which holds
94
+ # under a second at ~77 pkt/s (30 fps video + ~47 fps AAC); once timeshift
95
+ # delays the output the queue fills and, with drop_pkts_on_overflow=1, the
96
+ # stream is destroyed rather than delayed. Measured: ts=3 with the default
97
+ # queue yields 1 frame out of 480; ts=3 with 5000 and ts=10 with 20000 both
98
+ # yield byte-identical output to the no-fifo case.
99
+ q=max(500,int(os.getenv('YOUTUBE_FIFO_QUEUE','0')) or ts*250+500)
100
+ output=['-f','fifo','-fifo_format','flv','-format_opts','flvflags=no_duration_filesize',
101
+ '-drop_pkts_on_overflow','1','-attempt_recovery','1','-restart_with_keyframe','1',
102
+ '-recovery_wait_time',os.getenv('YOUTUBE_RECOVERY_WAIT','1'),
103
+ *(('-timeshift',str(ts),'-queue_size',str(q)) if ts>0 else ())]
104
+ else:
105
+ output=['-flvflags','no_duration_filesize','-f','flv']
106
+ return [self.ffmpeg,'-hide_banner','-loglevel','error','-nostats','-progress','pipe:1',
107
+ '-fflags','+genpts','-i','pipe:0','-map','0:v:0','-map','0:a:0?',
108
+ *video,'-c:a','aac','-b:a','128k','-ar','48000',
109
+ '-af','aresample=async=1:first_pts=0','-avoid_negative_ts','make_zero',
110
+ *enhanced,'-rw_timeout','20000000',*output,destination]
111
+ def send(self,user,candidate,destination,stop):
112
+ # YouTube's ingest is created with cdn.resolution/frameRate = 'variable', i.e. it
113
+ # auto-detects the incoming resolution, so a source resolution change does not
114
+ # require a new session. Tearing the session down is what produced the visible
115
+ # gaps and the reconnect storm. The guard stays available behind an env var in
116
+ # case YouTube's own healthStatus ever reports videoResolutionUnsupported.
117
+ guard=os.getenv('YOUTUBE_FORMAT_GUARD','off').lower() in ('1','on','true','yes')
118
+ bridge=FLVBridge(detect_format_change=(self.send_mode()=='copy' and guard))
119
+ response=None;p=None;error_reader=None;done=threading.Event()
120
+ # Set when we deliberately tear FFmpeg down because the source resolution
121
+ # changed; suppresses the misleading "FFmpeg exited with code N" log.
122
+ format_changed=False
123
+ progress={'last':time.monotonic(),'seconds':-1}
124
+ self.set(status='الاتصال بيوتيوب',source=candidate['label'],output_seconds=0,last_output_at=None)
125
+ try:
126
+ response=requests.get(candidate['url'],headers={'Referer':'https://www.tiktok.com/',**self.headers_for(user)},impersonate='chrome',stream=True,timeout=20)
127
+ response.raise_for_status()
128
+ p=subprocess.Popen(self.command(destination),stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,bufsize=0)
129
+ with self.lock:self.process=p
130
+ diagnostics=[]
131
+ def read_errors():
132
+ # Classify selected FFmpeg errors; never expose raw stderr or stream URLs.
133
+ patterns=[('Connection refused','رفض اتصال خادم الاستقبال'),('Connection timed out','انتهت مهلة اتصال خادم الاستقبال'),
134
+ ('Connection reset by peer','خادم الاستقبال قطع الاتصال أثناء الإرسال'),('Broken pipe','انقطع أنبوب الإرسال إلى خادم الاستقبال'),
135
+ ('End of file','خادم الاستقبال أغلق الاتصال'),('Immediate exit requested','طُلب إنهاء FFmpeg قبل اكتمال الإرسال'),
136
+ ('Packet mismatch','عدم تطابق حزم الفيديو — تغيّر مقاس المصدر داخل الجلسة'),
137
+ ('Input/output error','خطأ إدخال/إخراج في الإرسال'),('Server error','خادم الاستقبال أعاد خطأ'),
138
+ ('Invalid data found','بيانات المصدر غير صالحة للقراءة'),('Video codec','عدم توافق ترميز الفيديو مع الحاوية'),
139
+ ('Invalid argument','أحد إعدادات FFmpeg غير مقبول'),('Non-monotonic','توقيت الفيديو غير متزايد'),
140
+ ('Unknown encoder','مرمز غير متوفر'),('Option not found','خيار FFmpeg غير مدعوم')]
141
+ try:
142
+ for line in iter(p.stderr.readline,b''):
143
+ text=line.decode('utf-8','replace')
144
+ for needle,label in patterns:
145
+ if needle.lower() in text.lower() and label not in diagnostics:diagnostics.append(label)
146
+ except (OSError,ValueError):pass
147
+ error_reader=threading.Thread(target=read_errors,daemon=True);error_reader.start()
148
+ def read_progress():
149
+ try:
150
+ for line in iter(p.stdout.readline,b''):
151
+ if line.startswith(b'out_time_us='):
152
+ try:seconds=int(line.split(b'=',1)[1])/1000000
153
+ except ValueError:continue
154
+ if seconds>progress['seconds']:
155
+ progress['seconds']=seconds;progress['last']=time.monotonic()
156
+ self.set(status='FFmpeg يرسل — تحقق من معاينة YouTube Studio',output_seconds=round(seconds,1),last_output_at=time.time())
157
+ except (OSError,ValueError):pass
158
+ def watchdog():
159
+ while not done.wait(1):
160
+ if stop.is_set() or time.monotonic()-progress['last']>60:
161
+ if p.poll() is None:
162
+ try:p.kill()
163
+ except OSError:pass
164
+ return
165
+ threading.Thread(target=read_progress,daemon=True).start()
166
+ threading.Thread(target=watchdog,daemon=True).start()
167
+ for chunk in response.iter_content():
168
+ if stop.is_set() or p.poll() is not None:break
169
+ if chunk:
170
+ # Unbuffered writes may be partial; send every byte.
171
+ view=memoryview(bridge.feed(chunk))
172
+ while view and not stop.is_set():
173
+ n=p.stdin.write(view)
174
+ if not n:raise BrokenPipeError()
175
+ view=view[n:]
176
+ except FormatChanged:
177
+ format_changed=True
178
+ raise
179
+ finally:
180
+ if response:response.close()
181
+ if p:
182
+ try:p.stdin.close()
183
+ except (OSError,ValueError):pass
184
+ try:p.wait(timeout=10)
185
+ except subprocess.TimeoutExpired:p.kill();p.wait()
186
+ done.set()
187
+ if error_reader:error_reader.join(timeout=1)
188
+ if p.returncode and not stop.is_set() and not format_changed:
189
+ self.log(user,'FFmpeg خرج برمز '+str(p.returncode)+(' — '+'؛ '.join(diagnostics[-3:]) if diagnostics else '؛ لم يظهر سبب مصنّف'))
190
+ with self.lock:self.process=None
191
+ return progress['seconds']>0
requirements.txt CHANGED
@@ -1,4 +1,3 @@
1
- flask>=3.0
2
- requests>=2.31
3
- playwright>=1.40
4
- playwright-stealth>=2.0
 
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(' كل فحوصات التوكن نجحت.')
viewer.py DELETED
@@ -1,256 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- TikTok LIVE Viewer v5 — REAL browser with stealth.
4
- DOM structure: chat-message > flex-col > [name-row, break-words(comment)]
5
- """
6
- import os, sys, json, time, re, threading
7
- from flask import Flask, jsonify, render_template_string
8
-
9
- COMMENTS, VIEWER_COUNT, STREAMER_NAME = [], 0, ""
10
- IS_LIVE, HLS_URL, PK_DATA = False, "", {}
11
- BROWSER_STATUS = "starting"
12
- LOCK = threading.Lock()
13
- SEEN_FPS = set()
14
-
15
- EXTRACT_JS = '''() => {
16
- const msgs = [];
17
- document.querySelectorAll('[data-e2e="chat-message"]').forEach(el => {
18
- const ne = el.querySelector('[data-e2e="message-owner-name"]');
19
- const name = ne ? ne.textContent.trim() : '';
20
- // chat-message > avatar-div + content-div(flex-col) > [name-row-div, break-words-div]
21
- const flexCol = el.querySelector('.flex.flex-col');
22
- const commentDiv = flexCol && flexCol.children.length >= 2 ? flexCol.children[1] : null;
23
- const comment = commentDiv ? commentDiv.textContent.trim() : '';
24
- if (comment) msgs.push({user: name, text: comment, type: 'comment', fp: name + ':' + comment.substring(0, 30)});
25
- });
26
- document.querySelectorAll('[data-e2e="enter-message"]').forEach(el => {
27
- const ne = el.querySelector('[data-e2e="message-owner-name"]');
28
- if (ne) msgs.push({user: ne.textContent.trim(), text: 'joined', type: 'join', fp: 'j:' + ne.textContent.trim()});
29
- });
30
- return {
31
- msgs: msgs,
32
- viewers: (document.querySelector('[data-e2e="person-count"]') || {}).textContent || '0',
33
- name: (document.querySelector('[data-e2e="room-header-anchor-name"]') || {}).textContent || '',
34
- e2eCount: new Set(Array.from(document.querySelectorAll('[data-e2e]')).map(e => e.getAttribute('data-e2e'))).size,
35
- isLive: !!document.querySelector('[data-e2e="person-count"]') || document.querySelectorAll('[data-e2e="chat-message"]').length > 0
36
- };
37
- }'''
38
-
39
- app = Flask(__name__)
40
-
41
- HTML = """<!DOCTYPE html>
42
- <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
43
- <title>TikTok LIVE Viewer</title>
44
- <style>
45
- *{margin:0;padding:0;box-sizing:border-box}
46
- body{font-family:system-ui,sans-serif;background:#0a0a0a;color:#fff;height:100vh;overflow:hidden}
47
- .wrap{display:grid;grid-template-columns:1fr 360px;height:100vh}
48
- .vid{background:#000;display:flex;align-items:center;justify-content:center}
49
- .vid video{width:100%;height:100%;object-fit:contain}
50
- .chat{display:flex;flex-direction:column;background:#111;border-left:1px solid #222}
51
- .hdr{padding:12px 16px;background:#1a1a1a;border-bottom:1px solid #333}
52
- .hdr h2{font-size:1.1em;color:#fe2c55}
53
- .hdr .m{font-size:.85em;color:#888;margin-top:4px}
54
- .vc{display:inline-flex;align-items:center;gap:4px;background:#333;padding:2px 8px;border-radius:12px;font-size:.8em}
55
- .vc .d{width:6px;height:6px;background:#fe2c55;border-radius:50%;animation:p 1.5s infinite}
56
- @keyframes p{0%,100%{opacity:1}50%{opacity:.3}}
57
- .msgs{flex:1;overflow-y:auto;padding:8px;display:flex;flex-direction:column;gap:4px;scroll-behavior:smooth}
58
- .mg{padding:6px 10px;border-radius:8px;font-size:.9em;line-height:1.4;animation:fi .3s ease}
59
- @keyframes fi{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
60
- .mg.c{background:#1a1a2e}.mg.c .u{color:#fe2c55;font-weight:600;margin-right:6px}.mg.c .t{color:#e0e0e0}
61
- .mg.g{background:#1a1a0a;border-left:3px solid #ffd700}.mg.g .u{color:#ffd700;font-weight:600}
62
- .mg.j{background:#0a1a0a;color:#4ade80;font-size:.8em}
63
- .pk{padding:8px 16px;background:#1a1a1a;border-bottom:1px solid #333;display:none}
64
- .pk.on{display:block}.pk .bar{height:8px;background:#333;border-radius:4px;overflow:hidden;margin-top:4px}
65
- .pk .fill{height:100%;background:linear-gradient(90deg,#fe2c55,#25f4ee);border-radius:4px;transition:width .5s}
66
- .pk .sc{display:flex;justify-content:space-between;font-size:.85em;margin-top:4px}
67
- .ctrl{padding:8px 16px;background:#1a1a1a;border-top:1px solid #333;display:flex;gap:8px;align-items:center}
68
- .ctrl button{padding:6px 12px;border:none;border-radius:6px;background:#fe2c55;color:#fff;cursor:pointer;font-size:.85em}
69
- .st{padding:4px 16px;background:#0a0a0a;font-size:.75em;color:#666;text-align:center}
70
- </style></head><body>
71
- <div class="wrap">
72
- <div class="vid"><div id="ns" style="color:#888;font-size:1.2em">Connecting...</div>
73
- <video id="v" autoplay muted playsinline style="display:none"></video></div>
74
- <div class="chat">
75
- <div class="hdr"><h2 id="sn">Loading...</h2>
76
- <div class="m"><span class="vc"><span class="d"></span> <span id="vc">0</span></span>
77
- <span id="st" style="margin-left:8px">Connecting...</span></div></div>
78
- <div id="pk" class="pk"><div class="sc"><span id="pl">0</span><span>PK</span><span id="pr">0</span></div>
79
- <div class="bar"><div id="pf" class="fill" style="width:50%"></div></div></div>
80
- <div id="msgs" class="msgs"></div>
81
- <div class="ctrl"><button onclick="location.reload()">Refresh</button>
82
- <button onclick="document.getElementById('msgs').innerHTML=''">Clear</button></div>
83
- <div class="st" id="bs">Browser: starting...</div>
84
- </div>
85
- </div>
86
- <script>
87
- let hls=null;const v=document.getElementById('v'),ms=document.getElementById('msgs'),MX=200;
88
- function aM(t,u,x){const d=document.createElement('div');d.className='mg '+t;
89
- if(t==='c')d.innerHTML='<span class="u">'+e(u)+'</span><span class="t">'+e(x)+'</span>';
90
- else if(t==='g')d.innerHTML='\ud83c\udf81 <span class="u">'+e(u)+'</span> '+e(x);
91
- else if(t==='j')d.innerHTML='\ud83d\udc4b '+e(u)+' joined';
92
- else d.textContent=x||'';ms.appendChild(d);
93
- while(ms.children.length>MX)ms.removeChild(ms.firstChild);ms.scrollTop=ms.scrollHeight;}
94
- function e(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
95
- let sk=new Set();
96
- function poll(){fetch('/api/data').then(r=>r.json()).then(d=>{
97
- document.getElementById('sn').textContent=d.streamer||'Unknown';
98
- document.getElementById('vc').textContent=d.viewers||'0';
99
- document.getElementById('st').textContent=d.is_live?'LIVE':'Offline';
100
- document.getElementById('bs').textContent='Browser: '+d.browser_status;
101
- (d.comments||[]).forEach(c=>{const k=c.user+':'+(c.text||'').substring(0,30);
102
- if(sk.has(k))return;sk.add(k);if(sk.size>1000){const a=[...sk];sk=new Set(a.slice(-500));}
103
- aM(c.type==='gift'?'g':c.type==='join'?'j':'c',c.user||'',c.text||'');});
104
- if(d.pk&&d.pk.active){document.getElementById('pk').classList.add('on');
105
- document.getElementById('pl').textContent=d.pk.left_score||'0';
106
- document.getElementById('pr').textContent=d.pk.right_score||'0';}
107
- else document.getElementById('pk').classList.remove('on');
108
- if(d.hls&&!hls){v.style.display='block';document.getElementById('ns').style.display='none';
109
- if(window.Hls&&Hls.isSupported()){hls=new Hls();hls.loadSource(d.hls);hls.attachMedia(v);}
110
- else v.src=d.hls;}}).catch(()=>{});setTimeout(poll,1500);}
111
- const s=document.createElement('script');s.src='https://cdn.jsdelivr.net/npm/hls.js@latest';
112
- s.onload=()=>{window.Hls=Hls;};document.head.appendChild(s);poll();
113
- </script></body></html>"""
114
-
115
- @app.route('/')
116
- def index(): return render_template_string(HTML)
117
-
118
- @app.route('/api/data')
119
- def api_data():
120
- with LOCK:
121
- return jsonify({'streamer': STREAMER_NAME, 'viewers': VIEWER_COUNT, 'is_live': IS_LIVE,
122
- 'hls': HLS_URL, 'comments': COMMENTS[-100:], 'pk': PK_DATA,
123
- 'browser_status': BROWSER_STATUS})
124
-
125
- def parse_viewers(raw):
126
- s = str(raw).replace(',', '').strip()
127
- if 'K' in s:
128
- try: return int(float(s.replace('K','')) * 1000)
129
- except: return 0
130
- try: return int(re.sub(r'[^0-9]', '', s) or 0)
131
- except: return 0
132
-
133
- def browser_thread():
134
- global STREAMER_NAME, VIEWER_COUNT, IS_LIVE, HLS_URL, PK_DATA
135
- global BROWSER_STATUS, COMMENTS, SEEN_FPS
136
-
137
- from playwright.sync_api import sync_playwright
138
- from playwright_stealth import Stealth
139
-
140
- UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
141
-
142
- cookie_file = os.environ.get('COOKIE_FILE', '/home/user/tiktok-bot/cookies.json')
143
- cookies = []
144
- if os.path.exists(cookie_file):
145
- with open(cookie_file) as f:
146
- for c in json.load(f):
147
- cookies.append({'name': c['name'], 'value': c['value'],
148
- 'domain': c.get('domain', '.tiktok.com'), 'path': c.get('path', '/')})
149
-
150
- with LOCK: BROWSER_STATUS = "launching browser..."
151
-
152
- stealth = Stealth()
153
- pw = stealth.use_sync(sync_playwright())
154
- p = pw.start()
155
- browser = p.chromium.launch(headless=False, args=['--no-sandbox', '--autoplay-policy=no-user-gesture-required', '--disable-gpu'])
156
- context = browser.new_context(viewport={'width': 1280, 'height': 720}, user_agent=UA, locale='en-US')
157
- if cookies: context.add_cookies(cookies)
158
- page = context.new_page()
159
-
160
- def find_live_streamers():
161
- with LOCK: BROWSER_STATUS = "discovering live streamers..."
162
- try:
163
- page.goto('https://www.tiktok.com/live', wait_until='domcontentloaded', timeout=25000)
164
- time.sleep(6)
165
- return page.eval_on_selector_all('a[href*="/@"]',
166
- 'els => { const s=new Set(); return els.map(e=>e.href).filter(h=>h.match(/tiktok\\.com\\/@[^/]+\\/live/)).filter(h=>{if(s.has(h))return false;s.add(h);return true;}); }')
167
- except Exception as e:
168
- with LOCK: BROWSER_STATUS = f"discover error: {str(e)[:60]}"
169
- return []
170
-
171
- def open_live(url):
172
- global STREAMER_NAME, VIEWER_COUNT, IS_LIVE, BROWSER_STATUS, COMMENTS, SEEN_FPS
173
- with LOCK: BROWSER_STATUS = f"opening {url.split('/')[-2]}..."
174
- try:
175
- page.goto(url, wait_until='domcontentloaded', timeout=30000)
176
- time.sleep(12)
177
-
178
- has_captcha = page.evaluate("() => { const t=document.body.innerText; return (t.includes('Select')&&t.includes('shape'))||t.includes('Verify you are human'); }")
179
- if has_captcha:
180
- with LOCK: BROWSER_STATUS = "CAPTCHA - waiting 30s..."
181
- time.sleep(30)
182
- return False
183
-
184
- data = page.evaluate(EXTRACT_JS)
185
- e2e_c = data.get('e2eCount', 0)
186
- if e2e_c < 35:
187
- time.sleep(8)
188
- data = page.evaluate(EXTRACT_JS)
189
- e2e_c = data.get('e2eCount', 0)
190
-
191
- with LOCK:
192
- for m in data.get('msgs', []):
193
- fp = m.get('fp', '')
194
- if fp and fp not in SEEN_FPS:
195
- SEEN_FPS.add(fp)
196
- COMMENTS.append(m)
197
- if data.get('name'): STREAMER_NAME = data['name']
198
- VIEWER_COUNT = parse_viewers(data.get('viewers', '0'))
199
- IS_LIVE = data.get('isLive', False)
200
- BROWSER_STATUS = f"live! e2e={e2e_c} chat={len(data.get('msgs',[]))} v={VIEWER_COUNT}"
201
- return e2e_c >= 35
202
- except Exception as e:
203
- with LOCK: BROWSER_STATUS = f"open error: {str(e)[:60]}"
204
- return False
205
-
206
- def poll_loop():
207
- global STREAMER_NAME, VIEWER_COUNT, IS_LIVE, BROWSER_STATUS, COMMENTS, SEEN_FPS
208
- while True:
209
- try:
210
- data = page.evaluate(EXTRACT_JS)
211
- with LOCK:
212
- for m in data.get('msgs', []):
213
- fp = m.get('fp', '')
214
- if fp and fp not in SEEN_FPS:
215
- SEEN_FPS.add(fp)
216
- COMMENTS.append(m)
217
- if len(COMMENTS) > 500: COMMENTS[:] = COMMENTS[-500:]
218
- if data.get('name'): STREAMER_NAME = data['name']
219
- VIEWER_COUNT = parse_viewers(data.get('viewers', '0'))
220
- IS_LIVE = data.get('isLive', False)
221
- time.sleep(2)
222
- except Exception as e:
223
- with LOCK: BROWSER_STATUS = f"poll error: {str(e)[:60]}"
224
- time.sleep(5)
225
-
226
- # Main loop
227
- with LOCK: BROWSER_STATUS = "browser ready, discovering..."
228
- while True:
229
- try:
230
- live_urls = find_live_streamers()
231
- with LOCK: BROWSER_STATUS = f"found {len(live_urls)} streamers"
232
- if not live_urls:
233
- time.sleep(30)
234
- continue
235
-
236
- opened = False
237
- for url in live_urls[:5]:
238
- if open_live(url):
239
- opened = True
240
- break
241
- time.sleep(3)
242
-
243
- if opened:
244
- with LOCK: BROWSER_STATUS = f"watching {STREAMER_NAME}"
245
- poll_loop()
246
- else:
247
- with LOCK: BROWSER_STATUS = "no streamer worked, retrying in 60s..."
248
- time.sleep(60)
249
- except Exception as e:
250
- with LOCK: BROWSER_STATUS = f"main error: {str(e)[:60]}"
251
- time.sleep(10)
252
-
253
- if __name__ == '__main__':
254
- t = threading.Thread(target=browser_thread, daemon=True)
255
- t.start()
256
- app.run(host='0.0.0.0', port=7860, debug=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web.py DELETED
@@ -1,605 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- TikTok Live Multi-Account Bot v4
4
- - REAL likes via /webcast/room/digg/ (X-Bogus signed)
5
- - Multi-account PARALLEL (all accounts run simultaneously)
6
- - Fast likes, comments, shares
7
- - Pure Python X-Bogus generator (no browser needed)
8
- """
9
-
10
- import os, json, time, threading, re, random, logging, requests, hashlib, base64, struct
11
- from datetime import datetime
12
- from urllib.parse import urlparse
13
- from concurrent.futures import ThreadPoolExecutor
14
- from flask import Flask, render_template_string, request, jsonify
15
-
16
- logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s')
17
- log = logging.getLogger(__name__)
18
- app = Flask(__name__)
19
-
20
- # ═══════════════════════════════════════════════════════════════════════════════
21
- # X-Bogus Signature Generator (Pure Python)
22
- # ═══════════════════════════════════════════════════════════════════════════════
23
- _C64 = "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe="
24
- _S64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
25
- _UK = bytes([0x00, 0x01, 0x0E])
26
- _PK = bytes([0xFF])
27
- _MG = 0x4A41279F
28
-
29
- def _rc4(k, d):
30
- S = list(range(256)); j = 0
31
- for i in range(256): j = (j + S[i] + k[i % len(k)]) % 256; S[i], S[j] = S[j], S[i]
32
- r = bytearray(); i = j = 0
33
- for b in d: i = (i+1) % 256; j = (j+S[i]) % 256; S[i], S[j] = S[j], S[i]; r.append(b ^ S[(S[i]+S[j]) % 256])
34
- return bytes(r)
35
-
36
- def _md5h(d): return hashlib.md5(d.encode() if isinstance(d, str) else d).hexdigest()
37
- def _dmd5(d): return bytes.fromhex(_md5h(bytes.fromhex(_md5h(d))))
38
- def _uamd5(ua): return bytes.fromhex(_md5h(base64.b64encode(_rc4(_UK, ua.encode())).decode("iso-8859-1")))
39
-
40
- def x_bogus(qs, ua, body="", ts=None):
41
- ts = ts or int(time.time())
42
- p, b, u = _dmd5(qs), _dmd5(body or ""), _uamd5(ua)
43
- pl = bytearray([0x40]); pl.extend(_UK); pl.extend(p[14:16]); pl.extend(b[14:16])
44
- pl.extend(u[14:16]); pl.extend(struct.pack(">I", ts)); pl.extend(struct.pack(">I", _MG))
45
- x = 0
46
- for v in pl: x ^= v
47
- pl.append(x & 0xFF)
48
- enc = _rc4(_PK, bytes(pl))
49
- f = bytes([0x02, 0xFF]) + enc
50
- return base64.b64encode(f).decode().translate(str.maketrans(_S64, _C64))
51
-
52
- # ═══════════════════════════════════════════════════════════════════════════════
53
- # Constants
54
- # ═══════════════════════════════════════════════════════════════════════════════
55
- UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
56
- WEBCAST = "https://webcast.tiktok.com/webcast"
57
- CONFIG_PATH = os.path.join(os.path.dirname(__file__), 'config.json')
58
-
59
- def load_config():
60
- try:
61
- with open(CONFIG_PATH) as f: return json.load(f)
62
- except: return {}
63
-
64
- def save_config(cfg):
65
- with open(CONFIG_PATH, 'w') as f: json.dump(cfg, f, indent=2)
66
-
67
- # ═══════════════════════════════════════════════════════════════════════════════
68
- # State
69
- # ═══════════════════════════════════════════════════════════════════════════════
70
- session_log = []
71
- sessions = {}
72
- stop_flags = {}
73
- log_lock = threading.Lock()
74
- start_time = {}
75
-
76
- def add_log(msg, uid=None, level='info'):
77
- ts = datetime.now().strftime('%H:%M:%S')
78
- entry = {'ts': ts, 'uid': uid or 'system', 'msg': str(msg), 'level': level}
79
- with log_lock:
80
- session_log.append(entry)
81
- if len(session_log) > 1000: session_log.pop(0)
82
- getattr(log, level, log.info)(f"[{uid or 'sys'}] {msg}")
83
-
84
- # ═══════════════════════════════════════════════════════════════════════════════
85
- # Cookie Helpers
86
- # ═══════════════════════════════════════════════════════════════════════════════
87
- def parse_cookies(s):
88
- c = {}
89
- for p in s.split(';'):
90
- p = p.strip()
91
- if '=' in p:
92
- k, v = p.split('=', 1)
93
- c[k.strip()] = v.strip()
94
- return c
95
-
96
- def detect_name(cd):
97
- for k in ['username', 'nickname']:
98
- if k in cd: return cd[k]
99
- sid = cd.get('sessionid', '')
100
- return f"User-{sid[:8]}" if sid else "Unknown"
101
-
102
- def check_login(cd):
103
- return bool(cd.get('sessionid', ''))
104
-
105
- def refresh_session(cookies_dict, target_url):
106
- """Visit live page to get fresh tokens (ttwid, csrf, etc.)"""
107
- if '/live' not in target_url:
108
- target_url = target_url.rstrip('/') + '/live'
109
-
110
- s = requests.Session()
111
- for k, v in cookies_dict.items():
112
- s.cookies.set(k, v, domain=".tiktok.com")
113
-
114
- try:
115
- r = s.get(target_url, headers={'User-Agent': UA, 'Referer': 'https://www.tiktok.com/', 'Accept': 'text/html'}, timeout=15)
116
- fresh = {}
117
- for c in s.cookies:
118
- fresh[c.name] = c.value
119
-
120
- result = dict(cookies_dict)
121
- for k, v in fresh.items():
122
- if k not in result:
123
- result[k] = v
124
-
125
- match = re.search(r'"roomId"\s*:\s*"(\d+)"', r.text)
126
- room_id = match.group(1) if match else None
127
-
128
- # Also extract from page source if needed
129
- if not room_id:
130
- match2 = re.search(r'"id"\s*:\s*"(\d{15,})"', r.text)
131
- if match2:
132
- room_id = match2.group(1)
133
-
134
- return result, room_id
135
- except Exception as e:
136
- add_log(f"⚠️ Session refresh error: {e}", level='error')
137
- return cookies_dict, None
138
-
139
- def get_room_id(target_url):
140
- if '/live' not in target_url:
141
- target_url = target_url.rstrip('/') + '/live'
142
- try:
143
- r = requests.get(target_url, headers={'User-Agent': UA}, timeout=15)
144
- m = re.search(r'"roomId"\s*:\s*"(\d+)"', r.text)
145
- if m:
146
- add_log(f"✅ room_id: {m.group(1)}")
147
- return m.group(1)
148
- add_log("❌ No roomId found", level='error')
149
- return None
150
- except Exception as e:
151
- add_log(f"❌ room_id error: {e}", level='error')
152
- return None
153
-
154
- # ═══════════════════════════════════════════════════════════════════════════════
155
- # Webcast API Client
156
- # ═══════════════════════════════════════════════════════════════════════════════
157
- def _sign_url(path, params):
158
- qs = "&".join(f"{k}={v}" for k, v in params.items())
159
- xb = x_bogus(qs, UA)
160
- return f"{WEBCAST}{path}?{qs}&X-Bogus={xb}"
161
-
162
- def _headers(referer):
163
- return {
164
- 'User-Agent': UA,
165
- 'Referer': referer,
166
- 'Origin': 'https://www.tiktok.com',
167
- 'Accept': 'application/json',
168
- }
169
-
170
- class WebcastClient:
171
- def __init__(self, cookies, room_id, target_url):
172
- self.cookies = cookies
173
- self.room_id = room_id
174
- self.target_url = target_url
175
- self.headers = _headers(target_url)
176
- self.session = requests.Session()
177
- for k, v in cookies.items():
178
- self.session.cookies.set(k, v, domain=".tiktok.com")
179
-
180
- def digg(self, count=10):
181
- """Send likes via /webcast/room/digg/"""
182
- params = {"room_id": self.room_id, "count": str(count), "aid": "1988"}
183
- url = _sign_url("/room/digg/", params)
184
- r = self.session.post(url, headers=self.headers, timeout=10)
185
- data = json.loads(r.text) if r.text else {}
186
- return data.get('status_code', -1), data
187
-
188
- def chat(self, text):
189
- """Send chat via /webcast/room/chat/"""
190
- params = {"room_id": self.room_id, "content": text, "aid": "1988"}
191
- url = _sign_url("/room/chat/", params)
192
- r = self.session.post(url, headers=self.headers, timeout=10)
193
- data = json.loads(r.text) if r.text else {}
194
- return data.get('status_code', -1), data
195
-
196
- def share(self):
197
- """Share via /webcast/room/share/"""
198
- params = {"room_id": self.room_id, "aid": "1988"}
199
- url = _sign_url("/room/share/", params)
200
- r = self.session.post(url, headers=self.headers, timeout=10)
201
- data = json.loads(r.text) if r.text else {}
202
- return data.get('status_code', -1), data
203
-
204
- # ═══════════════════════════════════════════════════════════════════════════════
205
- # Worker (per account)
206
- # ═══════════════════════════════════════════════════════════════════════════════
207
- def worker(uid, cookie_str, target_url, like_interval, like_count, comment_interval, comments_list, result):
208
- stop_event = stop_flags[uid]
209
- sessions[uid] = {
210
- 'status': 'starting', 'likes': 0, 'comments': 0, 'shares': 0,
211
- 'errors': 0, 'account_name': '', 'driver_info': 'Webcast API (digg)'
212
- }
213
- start_time[uid] = time.time()
214
-
215
- cookies = parse_cookies(cookie_str)
216
- name = detect_name(cookies)
217
- sessions[uid]['account_name'] = name
218
- result['account_name'] = name
219
-
220
- if not check_login(cookies):
221
- add_log("❌ Invalid cookies", uid=uid, level='error')
222
- sessions[uid]['status'] = 'error'
223
- result['ok'] = False
224
- result['error'] = 'Invalid cookies'
225
- return
226
-
227
- add_log(f"✅ Logged in: {name}", uid=uid)
228
-
229
- # Refresh session
230
- add_log("🔄 Refreshing tokens...", uid=uid)
231
- cookies, room_id = refresh_session(cookies, target_url)
232
-
233
- if not room_id:
234
- add_log("❌ No room_id — is streamer live?", uid=uid, level='error')
235
- sessions[uid]['status'] = 'error'
236
- result['ok'] = False
237
- result['error'] = 'Not live or room_id not found'
238
- return
239
-
240
- result['ok'] = True
241
- sessions[uid]['status'] = 'running'
242
- add_log(f"🎯 Room: {room_id} | Likes/batch: {like_count}", uid=uid)
243
-
244
- client = WebcastClient(cookies, room_id, target_url)
245
-
246
- total_likes = 0
247
- total_comments = 0
248
- total_shares = 0
249
- next_comment = time.time() + random.randint(3, 8)
250
- next_share = time.time() + random.randint(30, 60)
251
-
252
- while not stop_event.is_set():
253
- now = time.time()
254
-
255
- # ── LIKES (fast batch) ──────────────────────────────────────────────
256
- try:
257
- status, data = client.digg(count=like_count)
258
- if status == 0:
259
- total_likes += like_count
260
- sessions[uid]['likes'] = total_likes
261
- if total_likes % (like_count * 10) == 0:
262
- elapsed = time.time() - start_time[uid]
263
- rate = int(total_likes / elapsed * 3600) if elapsed > 0 else 0
264
- add_log(f"❤️ {total_likes:,} likes ({rate:,}/hr)", uid=uid)
265
- else:
266
- sessions[uid]['errors'] += 1
267
- if status != 10013: # ignore "url does not match"
268
- add_log(f"⚠️ digg status: {status}", uid=uid, level='error')
269
- except Exception as e:
270
- sessions[uid]['errors'] += 1
271
- add_log(f"❌ digg error: {e}", uid=uid, level='error')
272
-
273
- # ── COMMENTS ────────────────────────────────────────────────────────
274
- if now >= next_comment and comments_list:
275
- comment = random.choice(comments_list)
276
- try:
277
- status, data = client.chat(comment)
278
- if status == 0:
279
- total_comments += 1
280
- sessions[uid]['comments'] = total_comments
281
- add_log(f"💬 '{comment}'", uid=uid)
282
- else:
283
- add_log(f"💬 chat status: {status}", uid=uid, level='error')
284
- except Exception as e:
285
- sessions[uid]['errors'] += 1
286
- add_log(f"❌ chat error: {e}", uid=uid, level='error')
287
- next_comment = now + comment_interval + random.uniform(-3, 3)
288
-
289
- # ── SHARES ──────────────────────────────────────────────────────────
290
- if now >= next_share:
291
- try:
292
- status, data = client.share()
293
- if status == 0:
294
- total_shares += 1
295
- sessions[uid]['shares'] = total_shares
296
- add_log(f"🔄 Share #{total_shares}", uid=uid)
297
- except Exception as e:
298
- sessions[uid]['errors'] += 1
299
- next_share = now + random.randint(45, 90)
300
-
301
- # ── WAIT ────────────────────────────────────────────────────────────
302
- stop_event.wait(like_interval)
303
-
304
- sessions[uid]['status'] = 'stopped'
305
- add_log(f"⏹️ Done — {total_likes:,} likes, {total_comments} comments, {total_shares} shares", uid=uid)
306
-
307
- # ═══════════════════════════════════════════════════════════════════════════════
308
- # API Routes
309
- # ═══════════════════════════════════════════════════════════════════════════════
310
- @app.route('/')
311
- def index():
312
- return render_template_string(HTML)
313
-
314
- @app.route('/api/start', methods=['POST'])
315
- def start():
316
- data = request.json
317
- accounts = data.get('accounts', [])
318
- target = data.get('target_url', 'https://www.tiktok.com/@u.9.1.5/live')
319
- like_interval = data.get('like_interval', 0.5)
320
- like_count = data.get('like_count', 20)
321
- comment_interval = data.get('comment_interval', 30)
322
- comments = data.get('comments', ['❤️','🔥','👏','💯','🎉'])
323
-
324
- results = []
325
- threads = []
326
-
327
- for acc in accounts:
328
- uid = acc.get('uid', '')
329
- cookies = acc.get('cookies', '')
330
- if not uid or not cookies:
331
- results.append({'uid': uid, 'ok': False, 'error': 'Missing uid or cookies'})
332
- continue
333
- if uid in stop_flags and not stop_flags[uid].is_set():
334
- results.append({'uid': uid, 'ok': False, 'error': 'Already running'})
335
- continue
336
-
337
- stop_event = threading.Event()
338
- stop_flags[uid] = stop_event
339
- res = {'uid': uid, 'ok': False}
340
-
341
- t = threading.Thread(target=worker, args=(
342
- uid, cookies, target, like_interval, like_count, comment_interval, comments, res
343
- ), daemon=True)
344
- t.start()
345
- threads.append(t)
346
- results.append(res)
347
-
348
- # Wait a bit for all threads to start
349
- time.sleep(1)
350
- started = sum(1 for r in results if r.get('ok'))
351
- return jsonify({'started': started, 'results': results})
352
-
353
- @app.route('/api/stop', methods=['POST'])
354
- def stop():
355
- uid = request.json.get('uid')
356
- if uid and uid in stop_flags:
357
- stop_flags[uid].set()
358
- return jsonify({'stopped': uid})
359
- return jsonify({'error': 'not found'}), 404
360
-
361
- @app.route('/api/stop_all', methods=['POST'])
362
- def stop_all():
363
- for uid, ev in stop_flags.items():
364
- ev.set()
365
- return jsonify({'stopped': len(stop_flags)})
366
-
367
- @app.route('/api/status')
368
- def status():
369
- return jsonify({'sessions': sessions, 'start_time': start_time})
370
-
371
- @app.route('/api/logs')
372
- def logs():
373
- since = int(request.args.get('since', 0))
374
- with log_lock:
375
- return jsonify({'logs': session_log[since:]})
376
-
377
- @app.route('/api/config', methods=['GET', 'POST'])
378
- def config():
379
- if request.method == 'GET':
380
- return jsonify(load_config())
381
- cfg = load_config()
382
- for k in ['target_url', 'like_interval', 'like_count', 'comment_interval', 'comments']:
383
- if k in request.json: cfg[k] = request.json[k]
384
- save_config(cfg)
385
- return jsonify({'ok': True})
386
-
387
- @app.route('/api/check_room', methods=['POST'])
388
- def check_room():
389
- target = request.json.get('target_url', '')
390
- room_id = get_room_id(target)
391
- return jsonify({'live': bool(room_id), 'room_id': room_id})
392
-
393
- # ═══════════════════════════════════════════════════════════════════════════════
394
- # HTML UI
395
- # ═══════════════════════════════════════════════════════════════════════════════
396
- HTML = r"""<!DOCTYPE html>
397
- <html lang="ar" dir="rtl">
398
- <head>
399
- <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
400
- <title>TikTok Bot v4</title>
401
- <style>
402
- *{margin:0;padding:0;box-sizing:border-box}
403
- :root{--bg:#0a0a0a;--card:#111;--border:#1e1e1e;--pink:#fe2c55;--green:#00c853;--blue:#25f4ee;--text:#e8e8e8;--dim:#666}
404
- body{background:var(--bg);color:var(--text);font-family:'Segoe UI',Tahoma,sans-serif;padding:12px;min-height:100vh}
405
- .header{text-align:center;padding:16px 0 8px}
406
- .header h1{font-size:26px;background:linear-gradient(135deg,var(--pink),var(--blue));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
407
- .header p{color:var(--dim);font-size:12px;margin-top:2px}
408
- .badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:700;background:var(--green);color:#000;margin-right:4px}
409
- .card{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:14px;margin:8px 0}
410
- .card h3{color:var(--pink);margin-bottom:10px;font-size:14px;display:flex;align-items:center;gap:6px}
411
- label{display:block;color:var(--dim);font-size:11px;margin-bottom:3px;margin-top:6px}
412
- input,textarea{width:100%;padding:9px;background:#181818;border:1px solid var(--border);border-radius:7px;color:var(--text);font-size:12px}
413
- textarea{resize:vertical;min-height:50px;font-family:monospace;font-size:11px}
414
- button{padding:9px 18px;border:none;border-radius:7px;font-size:12px;font-weight:700;cursor:pointer;transition:.15s}
415
- .btn-start{background:var(--green);color:#000;width:100%;margin-top:10px;font-size:14px;padding:12px}
416
- .btn-stop{background:var(--pink);color:#fff;width:100%;margin-top:6px}
417
- .btn-check{background:var(--blue);color:#000;margin-top:6px}
418
- button:hover{opacity:0.85;transform:scale(1.01)}
419
- .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:8px}
420
- .stat{text-align:center;padding:10px;background:#181818;border-radius:8px}
421
- .stat .num{font-size:22px;font-weight:800;color:var(--pink);font-variant-numeric:tabular-nums}
422
- .stat .lbl{font-size:10px;color:var(--dim);margin-top:2px}
423
- .s-card{background:#181818;border-radius:8px;padding:10px;margin:6px 0}
424
- .s-card .nm{font-weight:700;color:var(--blue);font-size:13px}
425
- .s-card .st{display:flex;gap:12px;margin-top:6px;font-size:12px}
426
- .s-card .st b{color:var(--green)}
427
- .log-box{background:#0c0c0c;border:1px solid var(--border);border-radius:7px;padding:8px;height:200px;overflow-y:auto;font-family:monospace;font-size:10px;line-height:1.5}
428
- .log-entry{padding:1px 0;border-bottom:1px solid #151515}
429
- .log-entry .ts{color:#444}.log-entry.err{color:#ff5252}.log-entry.ok{color:var(--green)}
430
- .dot{display:inline-block;width:7px;height:7px;border-radius:50%;margin-left:5px}
431
- .dot-r{background:var(--green);animation:p 1.5s infinite}.dot-s{background:#444}.dot-e{background:var(--pink)}
432
- @keyframes p{0%,100%{opacity:1}50%{opacity:.3}}
433
- .toast{position:fixed;top:16px;left:50%;transform:translateX(-50%);background:var(--green);color:#000;padding:8px 20px;border-radius:7px;font-weight:700;z-index:999;display:none;font-size:12px}
434
- .rows{display:flex;flex-wrap:wrap;gap:6px}.rows>*{flex:1;min-width:100px}
435
- .acc-btns{display:flex;gap:6px;margin-top:6px}
436
- .acc-btns button{flex:1;padding:6px;font-size:11px}
437
- </style>
438
- </head>
439
- <body>
440
- <div class="header">
441
- <h1>🎵 TikTok Live Bot v4</h1>
442
- <p><span class="badge">digg API</span><span class="badge">X-Bogus</span><span class="badge">Multi-Account</span></p>
443
- </div>
444
-
445
- <div class="card">
446
- <h3>🎯 الستريم المستهدف</h3>
447
- <input id="targetUrl" value="https://www.tiktok.com/@u.9.1.5/live">
448
- <button class="btn-check" onclick="checkRoom()">🔍 تحقق من الستريم</button>
449
- <div id="roomResult" style="margin-top:6px;font-size:11px;color:var(--dim)"></div>
450
- </div>
451
-
452
- <div class="card">
453
- <h3>🍪 الحسابات (حتى 5)</h3>
454
- <div id="accList"></div>
455
- <div class="acc-btns">
456
- <button onclick="addAcc()" style="background:#222;color:var(--text)">+ إضافة حساب</button>
457
- <button onclick="addAll()" style="background:#1a3a1a;color:var(--green)">📋 لصق الكل</button>
458
- </div>
459
- </div>
460
-
461
- <div class="card">
462
- <h3>💬 التعليقات</h3>
463
- <textarea id="comments">❤️
464
- 🔥
465
- 👏
466
- 💯
467
- 🎉
468
- hi
469
- hello
470
- nice
471
- amazing</textarea>
472
- </div>
473
-
474
- <div class="card">
475
- <h3>⚙️ الإعدادات</h3>
476
- <div class="rows">
477
- <div><label>فترة اللايك (ثانية)</label><input id="likeInt" type="number" value="0.5" min="0.1" step="0.1"></div>
478
- <div><label>لايك لكل مرة</label><input id="likeCount" type="number" value="20" min="1" max="500"></div>
479
- <div><label>فترة التعليق (ثانية)</label><input id="commentInt" type="number" value="30"></div>
480
- </div>
481
- </div>
482
-
483
- <div class="acc-btns">
484
- <button class="btn-start" onclick="startAll()">🚀 تشغيل الكل</button>
485
- <button class="btn-stop" onclick="stopAll()">⛔ إيقاف الكل</button>
486
- </div>
487
-
488
- <div class="card">
489
- <h3>📊 الإحصائيات</h3>
490
- <div class="grid">
491
- <div class="stat"><div class="num" id="sA">0</div><div class="lbl">حسابات</div></div>
492
- <div class="stat"><div class="num" id="sL">0</div><div class="lbl">إعجابات</div></div>
493
- <div class="stat"><div class="num" id="sC">0</div><div class="lbl">تعليقات</div></div>
494
- <div class="stat"><div class="num" id="sS">0</div><div class="lbl">مشاركات</div></div>
495
- <div class="stat"><div class="num" id="sR">0</div><div class="lbl">لايك/ساعة</div></div>
496
- </div>
497
- </div>
498
-
499
- <div class="card">
500
- <h3>👤 الحسابات</h3>
501
- <div id="sDiv"></div>
502
- </div>
503
-
504
- <div class="card">
505
- <h3>📋 السجل</h3>
506
- <div class="log-box" id="lBox"></div>
507
- </div>
508
-
509
- <div class="toast" id="toast"></div>
510
-
511
- <script>
512
- let accN=0,logI=0,sT={};
513
-
514
- function toast(m,c){const t=document.getElementById('toast');t.textContent=m;t.style.background=c||'var(--green)';t.style.display='block';setTimeout(()=>t.style.display='none',2500);}
515
-
516
- function addAcc(){
517
- if(accN>=5){toast('الحد الأقصى 5!','#ff5252');return;}
518
- accN++;
519
- const d=document.createElement('div');d.className='s-card';d.id='a'+accN;
520
- d.innerHTML=`<div style="display:flex;justify-content:space-between;align-items:center"><span class="nm">حساب ${accN}</span><button onclick="this.closest('.s-card').remove();accN--" style="background:#333;color:#fff;padding:3px 8px;font-size:10px;border:none;border-radius:5px;cursor:pointer">✕</button></div><label>Cookies</label><textarea class="ac" placeholder="sessionid=xxx;uid_tt=xxx;sid_tt=xxx;tt-target-idc=xxx" style="min-height:45px"></textarea>`;
521
- document.getElementById('accList').appendChild(d);
522
- }
523
-
524
- function addAll(){
525
- const text=prompt('الصق كل الكوكيز (سطر لكل حساب):');
526
- if(!text)return;
527
- const lines=text.split('\n').filter(s=>s.trim());
528
- lines.forEach(line=>{
529
- if(accN>=5)return;
530
- addAcc();
531
- const last=document.querySelector('.s-card:last-child .ac');
532
- if(last)last.value=line.trim();
533
- });
534
- }
535
-
536
- async function checkRoom(){
537
- const r=await(await fetch('/api/check_room',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({target_url:document.getElementById('targetUrl').value})})).json();
538
- const d=document.getElementById('roomResult');
539
- if(r.live){d.innerHTML=`<span style="color:var(--green)">✅ لايف! Room: <b>${r.room_id}</b></span>`;toast('الستريم شغال!');}
540
- else{d.innerHTML=`<span style="color:var(--pink)">❌ مو لايف</span>`;}
541
- }
542
-
543
- async function startAll(){
544
- const accounts=[];
545
- document.querySelectorAll('.s-card[id^="a"]').forEach(el=>{
546
- const c=el.querySelector('.ac')?.value?.trim();
547
- if(c)accounts.push({uid:'a_'+Math.random().toString(36).slice(2,8),cookies:c});
548
- });
549
- if(!accounts.length){toast('أضف حساب!','#ff5252');return;}
550
- const body={
551
- accounts,
552
- target_url:document.getElementById('targetUrl').value,
553
- like_interval:+document.getElementById('likeInt').value||0.5,
554
- like_count:+document.getElementById('likeCount').value||20,
555
- comment_interval:+document.getElementById('commentInt').value||30,
556
- comments:document.getElementById('comments').value.split('\n').filter(s=>s.trim())
557
- };
558
- const r=await(await fetch('/api/start',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})).json();
559
- toast(`شغّل ${r.started} حساب!`);
560
- }
561
-
562
- async function stopAll(){await fetch('/api/stop_all',{method:'POST'});toast('تم الإيقاف','#ff5252');}
563
-
564
- async function poll(){
565
- try{
566
- const st=await(await fetch('/api/status')).json();
567
- const ss=st.sessions||{};sT=st.start_time||{};
568
- let tL=0,tC=0,tSh=0,tA=0,html='';
569
- for(const[uid,s]of Object.entries(ss)){
570
- tA++;tL+=s.likes||0;tC+=s.comments||0;tSh+=s.shares||0;
571
- const dot=s.status==='running'?'dot-r':s.status==='error'?'dot-e':'dot-s';
572
- const el=sT[uid]?(Date.now()/1000-sT[uid]):0;
573
- const rate=el>0?Math.round((s.likes||0)/el*3600):0;
574
- html+=`<div class="s-card"><div style="display:flex;justify-content:space-between"><span class="nm">${s.account_name||uid}</span><span style="font-size:11px"><span class="dot ${dot}"></span>${s.status} | <b>${rate.toLocaleString()}</b>/ساعة</span></div><div class="st"><div>❤️<b>${(s.likes||0).toLocaleString()}</b></div><div>💬<b>${s.comments||0}</b></div><div>🔄<b>${s.shares||0}</b></div><div>❌<b>${s.errors||0}</b></div></div></div>`;
575
- }
576
- document.getElementById('sDiv').innerHTML=html||'<div style="color:var(--dim);font-size:12px">لايوجد حسابات</div>';
577
- document.getElementById('sA').textContent=tA;
578
- document.getElementById('sL').textContent=tL.toLocaleString();
579
- document.getElementById('sC').textContent=tC;
580
- document.getElementById('sS').textContent=tSh;
581
- const allT=Object.values(sT).length?Math.min(...Object.values(sT)):0;
582
- const el=allT?(Date.now()/1000-allT):0;
583
- document.getElementById('sR').textContent=el>0?Math.round(tL/el*3600).toLocaleString():'0';
584
- }catch(e){}
585
- try{
586
- const lg=await(await fetch(`/api/logs?since=${logI}`)).json();
587
- const box=document.getElementById('lBox');
588
- for(const l of(lg.logs||[])){
589
- logI++;
590
- box.innerHTML+=`<div class="log-entry ${l.level==='error'?'err':'ok'}"><span class="ts">[${l.ts}]</span> [${l.uid}] ${l.msg}</div>`;
591
- }
592
- box.scrollTop=box.scrollHeight;
593
- }catch(e){}
594
- }
595
-
596
- addAcc();addAcc(); // Start with 2 account slots
597
- setInterval(poll,1200);poll();
598
- </script>
599
- </body>
600
- </html>"""
601
-
602
- if __name__ == '__main__':
603
- add_log("🚀 TikTok Bot v4 — digg API + X-Bogus + Multi-Account")
604
- port = int(os.environ.get('PORT', 7860))
605
- app.run(host='0.0.0.0', port=port, debug=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
xbogus.py DELETED
@@ -1,180 +0,0 @@
1
- """
2
- TikTok X-Bogus Signature Generator — Pure Python Reimplementation
3
-
4
- Reverse-engineered from TikTok's client-side JavaScript (byted_acrawler / frontierSign).
5
-
6
- Algorithm:
7
- 1. Double-MD5 hash the URL query parameters
8
- 2. Double-MD5 hash the POST body (or empty string)
9
- 3. RC4-encrypt the User-Agent with key [0x00, 0x01, 0x0E], Base64 encode, then MD5
10
- 4. Build a 19-byte payload: [0x40, ua_key(3), params_md5(2), body_md5(2), ua_md5(2), timestamp(4), magic(4), xor_checksum(1)]
11
- 5. RC4-encrypt the payload with key [0xFF]
12
- 6. Prepend [0x02, 0xFF]
13
- 7. Custom Base64 encode with shifted alphabet
14
-
15
- No browser needed. No external TikTok libraries.
16
- """
17
-
18
- import hashlib
19
- import base64
20
- import time
21
- import struct
22
- from urllib.parse import urlencode, urlparse, parse_qs, urljoin
23
-
24
- # TikTok's custom Base64 alphabet (shifted from standard)
25
- CUSTOM_B64_ALPHABET = "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe="
26
- STANDARD_B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
27
-
28
- # RC4 key for User-Agent encryption
29
- UA_RC4_KEY = bytes([0x00, 0x01, 0x0E])
30
-
31
- # RC4 key for final payload encryption
32
- PAYLOAD_RC4_KEY = bytes([0xFF])
33
-
34
- # Magic constant for TikTok international (Douyin uses 0x20040510)
35
- MAGIC_CONSTANT = 0x4A41279F
36
-
37
- # Leading byte
38
- LEADING_BYTE = 0x40
39
-
40
-
41
- def _rc4(key: bytes, data: bytes) -> bytes:
42
- """Standard RC4 encryption/decryption."""
43
- S = list(range(256))
44
- j = 0
45
- for i in range(256):
46
- j = (j + S[i] + key[i % len(key)]) % 256
47
- S[i], S[j] = S[j], S[i]
48
-
49
- result = bytearray()
50
- i = j = 0
51
- for byte in data:
52
- i = (i + 1) % 256
53
- j = (j + S[i]) % 256
54
- S[i], S[j] = S[j], S[i]
55
- result.append(byte ^ S[(S[i] + S[j]) % 256])
56
- return bytes(result)
57
-
58
-
59
- def _md5_hex(data: bytes | str) -> str:
60
- """MD5 hash returning hex digest."""
61
- if isinstance(data, str):
62
- data = data.encode("utf-8")
63
- return hashlib.md5(data).hexdigest()
64
-
65
-
66
- def _hex_to_bytes(hex_str: str) -> bytes:
67
- """Convert hex string to bytes (parse hex pairs)."""
68
- return bytes.fromhex(hex_str)
69
-
70
-
71
- def _double_md5(data: bytes | str) -> bytes:
72
- """
73
- Double MD5: hash the data, convert hex digest to bytes, hash again.
74
- Returns 16 raw bytes.
75
- """
76
- first_hex = _md5_hex(data)
77
- first_bytes = _hex_to_bytes(first_hex)
78
- second_hex = _md5_hex(first_bytes)
79
- return _hex_to_bytes(second_hex)
80
-
81
-
82
- def _md5_user_agent(user_agent: str) -> bytes:
83
- """
84
- Process User-Agent: RC4 encrypt → Base64 encode → MD5.
85
- Returns 16 raw bytes.
86
- """
87
- ua_bytes = user_agent.encode("utf-8")
88
- rc4_encrypted = _rc4(UA_RC4_KEY, ua_bytes)
89
- b64_encoded = base64.b64encode(rc4_encrypted).decode("iso-8859-1")
90
- md5_hex = _md5_hex(b64_encoded)
91
- return _hex_to_bytes(md5_hex)
92
-
93
-
94
- def _custom_base64_encode(data: bytes) -> str:
95
- """Encode bytes using TikTok's custom Base64 alphabet."""
96
- # Standard Base64 encode
97
- standard = base64.b64encode(data).decode("ascii")
98
- # Character substitution: standard alphabet → custom alphabet
99
- trans = str.maketrans(STANDARD_B64_ALPHABET, CUSTOM_B64_ALPHABET)
100
- return standard.translate(trans)
101
-
102
-
103
- def generate_xbogus(
104
- query_string: str,
105
- user_agent: str,
106
- body: str = "",
107
- timestamp: int | None = None,
108
- ) -> str:
109
- """
110
- Generate X-Bogus signature for a TikTok API request.
111
-
112
- Args:
113
- query_string: The URL query string (without leading '?').
114
- e.g. "aid=1988&count=10&keyword=cooking"
115
- user_agent: The User-Agent header value (must match what you send).
116
- body: POST body string (empty for GET requests).
117
- timestamp: Unix timestamp in seconds. Defaults to current time.
118
-
119
- Returns:
120
- The X-Bogus parameter value to append to the URL.
121
- """
122
- if timestamp is None:
123
- timestamp = int(time.time())
124
-
125
- # Step 1: Double-MD5 of query parameters
126
- params_md5 = _double_md5(query_string)
127
-
128
- # Step 2: Double-MD5 of POST body
129
- body_md5 = _double_md5(body if body else "")
130
-
131
- # Step 3: MD5 of RC4-encrypted + Base64-encoded User-Agent
132
- ua_md5 = _md5_user_agent(user_agent)
133
-
134
- # Step 4: Build 19-byte payload
135
- payload = bytearray()
136
- payload.append(LEADING_BYTE) # 1 byte: 0x40
137
- payload.extend(UA_RC4_KEY) # 3 bytes: [0x00, 0x01, 0x0E]
138
- payload.extend(params_md5[14:16]) # 2 bytes from params double-MD5
139
- payload.extend(body_md5[14:16]) # 2 bytes from body double-MD5
140
- payload.extend(ua_md5[14:16]) # 2 bytes from UA MD5
141
- payload.extend(struct.pack(">I", timestamp)) # 4 bytes timestamp (big-endian)
142
- payload.extend(struct.pack(">I", MAGIC_CONSTANT)) # 4 bytes magic (big-endian)
143
-
144
- # XOR checksum of all 18 bytes
145
- xor_check = 0
146
- for b in payload:
147
- xor_check ^= b
148
- payload.append(xor_check & 0xFF) # 1 byte checksum → total 19 bytes
149
-
150
- # Step 5: RC4 encrypt with key [0xFF]
151
- encrypted = _rc4(PAYLOAD_RC4_KEY, bytes(payload))
152
-
153
- # Step 6: Prepend [0x02, 0xFF]
154
- final = bytes([0x02, 0xFF]) + encrypted # 21 bytes total
155
-
156
- # Step 7: Custom Base64 encode
157
- xbogus = _custom_base64_encode(final)
158
-
159
- return xbogus
160
-
161
-
162
- def sign_url(url: str, user_agent: str, body: str = "") -> str:
163
- """
164
- Sign a full TikTok API URL by appending the X-Bogus parameter.
165
-
166
- Args:
167
- url: Full URL with query string (e.g. "https://www.tiktok.com/api/search/...?aid=1988&...")
168
- user_agent: User-Agent string.
169
- body: POST body (empty for GET).
170
-
171
- Returns:
172
- The URL with X-Bogus appended.
173
- """
174
- parsed = urlparse(url)
175
- query_string = parsed.query
176
-
177
- xbogus = generate_xbogus(query_string, user_agent, body)
178
-
179
- separator = "&" if query_string else "?"
180
- return f"{url}{separator}X-Bogus={xbogus}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,605 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One managed YouTube event per live TikTok session, several events at the same time.
2
+ Each event carries the name/title/hashtags of the TikTok account it relays, and each
3
+ account gets its own reusable ingestion stream so concurrent events never mix.
4
+ Fail closed on ambiguous remote creates."""
5
+ import os, re, threading, time, uuid
6
+ from datetime import datetime, timezone, timedelta
7
+ from zoneinfo import ZoneInfo
8
+ from urllib.parse import urlparse
9
+ from curl_cffi import requests
10
+ from relay import Sender
11
+ from flv_bridge import FormatChanged
12
+ 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
+ self.max_events=max(1,int(os.getenv('YOUTUBE_MAX_EVENTS','3')))
26
+ self.last_logged=None;self.status_text='متوقف'
27
+ def marker(self):return 'QUSAI-AUTO-'+self.journal.read()['instance']
28
+ @staticmethod
29
+ def video_sources(sources):
30
+ mode=os.getenv('YOUTUBE_VIDEO_MODE','original').strip().lower()
31
+ if mode=='h264':return [s for s in sources if any(c in s['label'].lower() for c in ('h264','avc'))]
32
+ if mode!='original':raise RuntimeError('YOUTUBE_VIDEO_MODE يجب أن يكون original أو h264')
33
+ supported=[s for s in sources if any(c in s['label'].lower() for c in ('h264','avc','h265','hevc'))]
34
+ # Strict top source: no silent quality downgrade in original mode.
35
+ return supported[:1]
36
+ @staticmethod
37
+ def extract_hashtags(text):
38
+ """Hashtags come from the TikTok live title itself, deduplicated, at most 12."""
39
+ seen=[];out=[]
40
+ for tag in re.findall(r'#[^\s#]+',text or ''):
41
+ if tag not in seen:seen.append(tag);out.append(tag)
42
+ if len(out)>=12:break
43
+ return ' '.join(out)[:400]
44
+ @staticmethod
45
+ def description_with_links(text,user,hashtags):
46
+ """Per-account footer: the broadcaster's own TikTok profile + hashtags of this live.
47
+ No cross-account links, so concurrent events never mix attributions."""
48
+ text=text.rstrip()
49
+ link=f'https://www.tiktok.com/@{user}'
50
+ if link not in text:
51
+ text+=f'\n\n🎵 حساب المذيع على تيك توك\n@{user}\n{link}'
52
+ if hashtags and hashtags not in text:text+='\n\n'+hashtags
53
+ return re.sub(r'\n{3,}','\n\n',text)
54
+
55
+ # ---------- journal helpers ----------
56
+ def event_update(self,key,**kw):
57
+ def change(d):
58
+ ev=d['events'].get(key)
59
+ if ev is not None:ev.update(kw)
60
+ self.journal.change(change)
61
+ def event_note(self,key,text):self.event_update(key,note=text)
62
+ def streams_change(self,user,**kw):
63
+ def change(d):d['streams'].setdefault(user,{}).update(kw)
64
+ self.journal.change(change)
65
+ def set_status(self,text):self.status_text=text
66
+ @staticmethod
67
+ def safe_error(e):
68
+ if isinstance(e,(APIError,RuntimeError,ValueError)):return str(e)
69
+ # Keep the message generic (no raw network/traceback text can leak), but name the
70
+ # exception class. Without it a KeyError from a stale saved template is
71
+ # indistinguishable from any other internal fault and cannot be diagnosed.
72
+ return 'خطأ داخلي في إدارة يوتيوب؛ التوقف عن الإنشاء لحماية الأحداث ('+type(e).__name__+')'
73
+ def _event_error(self,key,e):
74
+ text=self.safe_error(e)
75
+ ev=self.journal.read()['events'].get(key) or {}
76
+ if text!=ev.get('note'):self.log(ev.get('username') or key,text)
77
+ self.event_note(key,text)
78
+ def _clear_stale_error_note(self,key):
79
+ """FIX C: clear the sticky 'حماية الأحداث' / 'خطأ داخلي' note after recovery.
80
+ Previously _event_error wrote the note and nothing ever cleared it, so a transient
81
+ SSLError left the UI stuck for 40 min even though the stream was healthy."""
82
+ ev=self.journal.read()['events'].get(key)
83
+ if not ev:return
84
+ note=ev.get('note') or ''
85
+ if note and ('حماية الأحداث' in note or 'خطأ داخلي' in note or 'networkOrInvalidResponse' in note):
86
+ self.event_update(key, note='')
87
+
88
+ # ---------- channel / settings ----------
89
+ def check_channel(self):
90
+ result=self.api.call('channels',params={'part':'id,snippet','mine':'true'})
91
+ channels=result.get('items',[])
92
+ if len(channels)!=1:raise RuntimeError('لم نجد قناة واحدة مفوضة؛ أعد OAuth واختر قناة يوتيوب الصحيحة')
93
+ channel={'id':channels[0]['id'],'title':channels[0]['snippet']['title']}
94
+ old=self.journal.read()['channel']
95
+ if old and old['id']!=channel['id']:raise RuntimeError('القناة المفوضة تغيرت؛ توقف آمن لحماية أحداث القناة السابقة')
96
+ self.journal.change(lambda d:d.update(channel=channel))
97
+ return channel
98
+ def check(self):
99
+ with self.operations:return self.check_channel()
100
+ def settings(self,values):
101
+ with self.operations:
102
+ d=self.journal.read()
103
+ if d['enabled'] or d['events']:
104
+ raise ValueError('عطّل الأتمتة وانتظر إغلاق الأحداث قبل تعديل قوالب الأحداث')
105
+ clean={k:str(values.get(k,'')).strip() for k in ('name','privacy','title','description')}
106
+ if clean['privacy'] not in ('public','unlisted','private'):raise ValueError('خصوصية غير صالحة')
107
+ if len(clean['name'])>80:raise ValueError('الاسم الاحتياطي حتى 80 حرفًا (الاسم الفعلي يُجلب من حساب تيك توك)')
108
+ test={'name':'فلان','title':'بث تجريبي','hashtags':'#تجربة','date':'11-09-2026','time':'16:30:00','username':'example','room_id':'123',
109
+ 'nickname':'فلان','live_title':'بث تجريبي'}
110
+ try:title=clean['title'].format(**test);desc=clean['description'].format(**test)
111
+ except (KeyError,ValueError,IndexError,AttributeError):raise ValueError('متغير قالب غير مدعوم')
112
+ if not title or len(title)>100 or any(c in title+desc for c in '<>') or len(desc)>4500:
113
+ raise ValueError('العنوان الناتج حتى 100 حرف والوصف حتى 4500، وبدون < أو >')
114
+ # Prevent arbitrary attribute/index lookups in format templates.
115
+ import string
116
+ for template in (clean['title'],clean['description']):
117
+ for _,field,spec,conv in string.Formatter().parse(template):
118
+ if field is not None and (field not in test or spec or conv):raise ValueError('استخدم متغيرات القالب البسيطة فقط')
119
+ self.journal.change(lambda x:x.update(settings=clean))
120
+ def snapshot(self):
121
+ d=self.journal.read()
122
+ events=[]
123
+ for key,ev in d['events'].items():
124
+ entry=self.senders.get(key)
125
+ s=entry['sender'].snapshot() if entry and entry['thread'].is_alive() else {}
126
+ events.append({'username':ev['username'],'nickname':ev.get('nickname',''),'room_id':ev['room_id'],
127
+ 'title':ev['title'],'phase':ev.get('phase'),'youtube_status':ev.get('youtube_status'),
128
+ 'closing':bool(ev.get('closing')),'broadcast_id':ev['broadcast_id'],'note':ev.get('note',''),
129
+ 'status':s.get('status',''),'source':s.get('source',''),'output_seconds':s.get('output_seconds',0),
130
+ # YouTube's own verdict on the inbound stream, so the UI can show why
131
+ # viewers buffer instead of us guessing at the cause.
132
+ 'youtube_health':ev.get('youtube_health',''),'youtube_issues':ev.get('youtube_issues','')})
133
+ # Effective ingestion mode, including the automatic anti-flap fallback, so the
134
+ # UI can show whether the constant-canvas protection has kicked in.
135
+ try:
136
+ probe_sender=Sender(self.ffmpeg,self.log,self.headers_for)
137
+ send_mode=probe_sender.send_mode()
138
+ except Exception:
139
+ send_mode=os.getenv('YOUTUBE_SEND_MODE','copy')
140
+ return {'status':self.status_text,'automatic':d['enabled'],'events':events,
141
+ 'video_mode':os.getenv('YOUTUBE_VIDEO_MODE','original'),'configured':self.api.configured(),
142
+ 'send_mode':send_mode,
143
+ 'watched_accounts':list(self.accounts()),'channel':d['channel'],'settings':d['settings'],
144
+ 'budget':d['budget'],'history_count':len(d['history']),'max_events':self.max_events}
145
+
146
+ # ---------- enable / disable / lifecycle ----------
147
+ def enable(self,expected_channel):
148
+ if os.getenv('YOUTUBE_VIDEO_MODE','original').strip().lower() not in ('original','h264'):raise ValueError('YOUTUBE_VIDEO_MODE يجب أن يكون original أو h264')
149
+ with self.operations:
150
+ if not self.api.configured():raise ValueError('أسرار OAuth الثلاثة غير مكتملة')
151
+ channel=self.check_channel()
152
+ if not expected_channel or expected_channel!=channel['id']:raise ValueError('افحص الربط وأكّد القناة الصحيحة قبل التفعيل')
153
+ if any(e.get('closing') for e in self.journal.read()['events'].values()):
154
+ raise ValueError('توجد أحداث قيد الإنهاء؛ انتظر اكتمال تنظيفها')
155
+ self.journal.change(lambda d:d.update(enabled=True));self.launch()
156
+ def disable(self):
157
+ with self.operations:
158
+ def change(d):
159
+ d['enabled']=False
160
+ for ev in d['events'].values():ev['closing']=True
161
+ self.journal.change(change)
162
+ for key in list(self.senders):self.stop_sender(key)
163
+ self.launch()
164
+ self.set_status('تعطيل الأتمتة وإنهاء الأحداث الجارية')
165
+ def launch(self):
166
+ if self.manager_thread and self.manager_thread.is_alive():return
167
+ self.manager_stop=threading.Event()
168
+ self.manager_thread=threading.Thread(target=self.run_manager,daemon=True);self.manager_thread.start()
169
+ def restore(self):
170
+ d=self.journal.read()
171
+ if d['enabled'] or d['events']:
172
+ self.launch()
173
+ def stop(self):
174
+ # Process shutdown is not a user-requested end. Preserve remote events for restart recovery.
175
+ self.manager_stop.set()
176
+ for key in list(self.senders):self.stop_sender(key)
177
+
178
+ # ---------- remote helpers ----------
179
+ def remote(self,resource,rid):
180
+ items=self.api.call(resource,params={'part':'id,snippet,status,contentDetails'+(',cdn' if resource=='liveStreams' else ''),'id':rid}).get('items',[])
181
+ return items[0] if items else None
182
+ def reconcile(self,resource,marker):
183
+ params={'part':'id,snippet,status,contentDetails','mine':'true','maxResults':50}
184
+ if resource=='liveBroadcasts':
185
+ params.pop('mine');params.update(broadcastStatus='all',broadcastType='all')
186
+ items=self.api.items(resource,params)
187
+ matches=[i for i in items if marker in i.get('snippet',{}).get('description','').splitlines()]
188
+ if len(matches)>1:raise RuntimeError('أحداث متكررة بنفس علامة التطبيق؛ توقف للمراجعة دون حذف تلقائي')
189
+ return matches[0] if matches else None
190
+
191
+ # ---------- per-account ingestion streams ----------
192
+ def stream_markers(self,user,legacy):
193
+ markers=[self.marker()+'-STREAM-'+user]
194
+ if legacy:markers.append(self.marker()+'-STREAM') # stream migrated from V6/V7
195
+ return markers
196
+ def ensure_stream(self,user):
197
+ d=self.journal.read();st=d['streams'].get(user) or {}
198
+ markers=self.stream_markers(user,bool(st.get('legacy')))
199
+ if not st.get('id'):
200
+ if st.get('pending'):
201
+ found=None
202
+ for m in markers:
203
+ found=self.reconcile('liveStreams',m)
204
+ if found:break
205
+ if not found:raise RuntimeError('إنشاء مصدر سابق غير محسوم؛ ننتظر المصالحة بدل إنشاء مفتاح مكرر')
206
+ self.streams_change(user,id=found['id'],pending=False)
207
+ else:
208
+ self.streams_change(user,pending=True)
209
+ try:
210
+ r=self.api.call('liveStreams','POST',{'part':'snippet,cdn,contentDetails'},
211
+ {'snippet':{'title':user+' — مصدر بث التطبيق القابل لإعادة الاستخدام','description':markers[0]},
212
+ 'cdn':{'ingestionType':'rtmp','resolution':'variable','frameRate':'variable'},'contentDetails':{'isReusable':True}})
213
+ except APIError as e:
214
+ if e.status and 400<=e.status<500:self.streams_change(user,pending=False)
215
+ raise
216
+ if not r.get('id'):raise RuntimeError('استجابة إنشاء المصدر غير محسومة؛ لن نكرر الإنشاء')
217
+ self.streams_change(user,id=r['id'],pending=False)
218
+ stream=self.remote('liveStreams',self.journal.read()['streams'][user]['id'])
219
+ if not stream or not any(m in stream.get('snippet',{}).get('description','').splitlines() for m in markers):
220
+ raise RuntimeError('مصدر التطبيق مفقود أو هويته تغيرت؛ لا ننشئ مفاتيح بديلة تلقائيًا')
221
+ ingest=stream.get('cdn',{}).get('ingestionInfo',{});key=ingest.get('streamName')
222
+ secure=ingest.get('rtmpsIngestionAddress')
223
+ if not secure:
224
+ # YouTube's secure endpoint; streamName remains in memory only.
225
+ secure='rtmps://a.rtmps.youtube.com:443/live2'
226
+ p=urlparse(secure)
227
+ if p.scheme!='rtmps' or not p.hostname or not p.hostname.endswith('.youtube.com') or not key:
228
+ raise RuntimeError('لم يُرجع يوتيوب عنوان إرسال آمنًا صالحًا')
229
+ self.destinations[user]=secure.rstrip('/')+'/'+key
230
+ return stream
231
+
232
+ # ---------- events ----------
233
+ def open_event(self,user,observation):
234
+ d=self.journal.read();key=user+':'+observation['room_id']
235
+ if key in d['events'] or any(h['session_key']==key for h in d['history']):return False
236
+ dt=datetime.now(ZoneInfo('Asia/Baghdad'));settings=d['settings']
237
+ nickname=str(observation.get('nickname') or '').strip()
238
+ live_title=str(observation.get('live_title') or '').strip()
239
+ name=nickname or user
240
+ title_value=live_title or (name+' — بث مباشر')
241
+ hashtags=self.extract_hashtags(live_title)
242
+ values={'name':name,'title':title_value,'hashtags':hashtags,'date':dt.strftime('%d-%m-%Y'),
243
+ 'time':dt.strftime('%H:%M:%S'),'username':user,'room_id':observation['room_id'],
244
+ # Legacy aliases. Journals written by older builds keep their saved
245
+ # templates, and those used {nickname}/{live_title}. Without these the
246
+ # format() below raises KeyError, which safe_error() masks into a generic
247
+ # "internal error" — so no event is ever created and nothing goes to air.
248
+ 'nickname':name,'live_title':live_title}
249
+ title=settings['title'].format(**values)[:100];marker=self.marker()+'-EVENT-'+uuid.uuid4().hex
250
+ desc=self.description_with_links(settings['description'].format(**values)[:4400],user,hashtags)+'\n\n'+marker
251
+ ev={'username':user,'room_id':observation['room_id'],'session_key':key,'marker':marker,
252
+ 'nickname':name,'live_title':live_title,'title':title,'description':desc,
253
+ 'privacy':settings['privacy'],'created_at':time.time(),'note':'',
254
+ 'broadcast_id':None,'create_pending':False,'phase':'planned','bound':False,'was_live':False,
255
+ 'closing':False,'last_poll':0,'offline_since':None,'offline_count':0}
256
+ def change(d):d['events'][key]=ev
257
+ self.journal.change(change);return True
258
+ def ensure_broadcast(self,key):
259
+ ev=self.journal.read()['events'][key]
260
+ if not ev['broadcast_id']:
261
+ if ev['create_pending']:
262
+ found=self.reconcile('liveBroadcasts',ev['marker'])
263
+ if not found:raise RuntimeError('إنشاء حدث سابق غير محسوم؛ لا نكرر POST لمنع التراكم. يلزم مراجعة إذا استمر')
264
+ self.event_update(key,broadcast_id=found['id'],create_pending=False,phase='created')
265
+ else:
266
+ self.event_update(key,create_pending=True,phase='creating')
267
+ body={'snippet':{'title':ev['title'],'description':ev['description'],
268
+ 'scheduledStartTime':(datetime.now(timezone.utc)+timedelta(seconds=30)).isoformat()},
269
+ 'status':{'privacyStatus':ev['privacy']},
270
+ 'contentDetails':{'enableAutoStart':False,'enableAutoStop':False,'enableDvr':True,
271
+ 'recordFromStart':True,'monitorStream':{'enableMonitorStream':False}}}
272
+ try:r=self.api.call('liveBroadcasts','POST',{'part':'snippet,status,contentDetails'},body)
273
+ except APIError as e:
274
+ if e.status and 400<=e.status<500:self.event_update(key,create_pending=False,phase='planned')
275
+ raise
276
+ if not r.get('id'):raise RuntimeError('استجابة إنشاء الحدث غير محسومة')
277
+ self.event_update(key,broadcast_id=r['id'],create_pending=False,phase='created')
278
+ ev=self.journal.read()['events'][key];remote=self.remote('liveBroadcasts',ev['broadcast_id'])
279
+ if not remote:return None
280
+ if ev['marker'] not in remote.get('snippet',{}).get('description','').splitlines():
281
+ raise RuntimeError('علامة ملكية حدث التطبيق تغيرت؛ توقف آمن دون تعديل الحدث')
282
+ if remote.get('status',{}).get('privacyStatus')!=ev['privacy']:
283
+ raise RuntimeError('خصوصية الحدث في يوتيوب لا تطابق المطلوبة؛ راجع قيود مشروع API قبل النشر')
284
+ if remote.get('status',{}).get('lifeCycleStatus') not in ('complete','revoked') and not ev['bound']:
285
+ stream_id=(self.journal.read()['streams'].get(ev['username']) or {}).get('id')
286
+ if remote.get('contentDetails',{}).get('boundStreamId')!=stream_id:
287
+ self.api.call('liveBroadcasts/bind','POST',{'part':'id,contentDetails','id':ev['broadcast_id'],'streamId':stream_id})
288
+ self.event_update(key,bound=True,phase='bound')
289
+ return remote
290
+ def archive_event(self,key,outcome):
291
+ def change(d):
292
+ ev=d['events'].pop(key,None)
293
+ if ev is None:return
294
+ d['history'].append({'session_key':ev['session_key'],'broadcast_id':ev['broadcast_id'],
295
+ 'title':ev['title'],'username':ev['username'],'nickname':ev.get('nickname','')})
296
+ d['history'][-1].update(outcome=outcome,ended_at=time.time())
297
+ self.journal.change(change)
298
+ self.stop_sender(key)
299
+ def finish_event(self,key):
300
+ ev=self.journal.read()['events'].get(key)
301
+ if not ev:return True
302
+ self.event_update(key,closing=True,phase='closing')
303
+ if not self.stop_sender(key):return False
304
+ ev=self.journal.read()['events'].get(key)
305
+ if not ev:return True
306
+ if not ev['broadcast_id']:
307
+ if ev['create_pending']:
308
+ found=self.reconcile('liveBroadcasts',ev['marker'])
309
+ if not found:raise RuntimeError('حدث غير محسوم عند الإنهاء؛ لا ننشئ أو نحذف بالتخمين')
310
+ self.event_update(key,broadcast_id=found['id'],create_pending=False)
311
+ else:self.archive_event(key,'cancelled_before_create');return True
312
+ ev=self.journal.read()['events'][key]
313
+ remote=self.remote('liveBroadcasts',ev['broadcast_id'])
314
+ if not remote:self.archive_event(key,'missing');return True
315
+ if ev['marker'] not in remote.get('snippet',{}).get('description','').splitlines():
316
+ raise RuntimeError('لا يمكن إنهاء حدث لا يحمل علامة هذه الجلسة')
317
+ status=remote.get('status',{}).get('lifeCycleStatus')
318
+ if status in ('complete','revoked'):
319
+ self.archive_event(key,status);return True
320
+ if status in ('live','testing'):
321
+ self.api.call('liveBroadcasts/transition','POST',{'part':'id,status','id':ev['broadcast_id'],'broadcastStatus':'complete'})
322
+ self.event_note(key,'طلب إنهاء الحدث أُرسل؛ التحقق من اكتماله');return False
323
+ # Never delete an event which actually started, even if local state missed the transition.
324
+ if status in ('created','ready') and not ev['was_live'] and not remote.get('snippet',{}).get('actualStartTime'):
325
+ self.api.call('liveBroadcasts','DELETE',{'id':ev['broadcast_id']})
326
+ self.archive_event(key,'empty_deleted');return True
327
+ self.event_note(key,'انتظار اكتمال انتقال حالة الحدث قبل إنهائه');return False
328
+
329
+ # ---------- senders ----------
330
+ def start_sender(self,key):
331
+ # FIX B: guard against 187 storm — don't push to RTMPS before YouTube ingest exists
332
+ # Previously called unconditionally from tick()/drive_event before broadcast was bound/ready,
333
+ # causing 7 ffmpeg failures in 5 min and 5:22 late start (~116s waste).
334
+ entry=self.senders.get(key)
335
+ if entry and entry['thread'].is_alive():return
336
+ ev=self.journal.read()['events'].get(key)
337
+ if not ev or ev.get('closing'):return
338
+ # Must be bound and have a destination; otherwise RTMPS will reject with 187
339
+ if not ev.get('bound') or not ev.get('broadcast_id'):
340
+ return
341
+ if ev.get('youtube_status') in ('complete','revoked','created'):
342
+ return
343
+ destination=self.destinations.get(ev['username'])
344
+ if not destination:raise RuntimeError('عنوان إرسال الحدث غير مهيأ')
345
+ stop=threading.Event();sender=Sender(self.ffmpeg,self.log,self.headers_for)
346
+ thread=threading.Thread(target=self.sender_loop,
347
+ args=(key,ev['username'],ev['room_id'],destination,stop,sender),daemon=True)
348
+ self.senders[key]={'thread':thread,'stop':stop,'sender':sender}
349
+ thread.start()
350
+ def stop_sender(self,key):
351
+ entry=self.senders.get(key)
352
+ if not entry:return True
353
+ entry['stop'].set()
354
+ entry['sender'].terminate()
355
+ if entry['thread'].is_alive():
356
+ entry['thread'].join(timeout=3)
357
+ if entry['thread'].is_alive():return False
358
+ self.senders.pop(key,None)
359
+ return True
360
+ def sender_loop(self,key,user,room_id,destination,stop,sender):
361
+ restart_at=0.0
362
+ while not stop.is_set():
363
+ try:
364
+ obs=self.observe(user)
365
+ if obs['status']!='live' or obs['room_id']!=room_id:return
366
+ sources=self.video_sources(obs['sources'])
367
+ for candidate in sources:
368
+ if stop.is_set():return
369
+ try:
370
+ if sender.send(user,candidate,destination,stop):break
371
+ except FormatChanged:
372
+ # Source resolution/layout changed (PK round started or ended, app
373
+ # resumed). Reconnect straight away so YouTube re-detects the new size.
374
+ sender.note_format_change()
375
+ self.log(user,'تغيرت أبعاد صورة المصدر أثناء البث؛ إعادة الاتصال فورًا بمقاس جديد')
376
+ restart_at=time.monotonic();break
377
+ except Exception:self.log(user,'تعثر إرسال الفيديو؛ نفس الحدث سيُستخدم')
378
+ except Exception:self.log(user,'تعذر تحديث مصدر الإرسال؛ لا ننشئ حدثًا إضافيًا')
379
+ # Skip the idle wait right after a resolution change; otherwise keep polling.
380
+ if restart_at and time.monotonic()-restart_at<30:restart_at=0.0;continue
381
+ stop.wait(15)
382
+ def probe(self,user,candidate):
383
+ # Check a readable FLV header before creating any public scheduled event.
384
+ r=requests.get(candidate['url'],impersonate='chrome',
385
+ headers={'Referer':'https://www.tiktok.com/',**self.headers_for(user)},stream=True,timeout=20)
386
+ try:
387
+ r.raise_for_status();prefix=b''
388
+ for chunk in r.iter_content():
389
+ prefix+=chunk
390
+ if len(prefix)>=3:return prefix[:3]==b'FLV'
391
+ return False
392
+ finally:r.close()
393
+ def _probe_ok(self,user,candidate):
394
+ try:return self.probe(user,candidate)
395
+ except Exception:return False
396
+
397
+ # ---------- main loop ----------
398
+ def tick(self):
399
+ d=self.journal.read()
400
+ accounts=list(self.accounts())
401
+ # 1) Close events that must close (user stop, disable, account removed).
402
+ for key,ev in list(d['events'].items()):
403
+ if ev.get('closing') or not d['enabled'] or ev['username'] not in accounts:
404
+ try:self.finish_event(key)
405
+ except Exception as e:self._event_error(key,e)
406
+ d=self.journal.read()
407
+ if not d['enabled']:
408
+ self.set_status('تعطيل الأتمتة وإنهاء الأحداث الجارية' if d['events'] else 'الأتمتة معطلة')
409
+ return
410
+ # 2) Discover new live sessions, one event per account, up to the concurrency cap.
411
+ cap=self.max_events;active=len(d['events']);capped=False
412
+ for user in accounts:
413
+ if active>=cap:capped=True;break
414
+ if any(e['username']==user for e in d['events'].values()):continue
415
+ try:obs=self.observe(user)
416
+ except Exception:continue
417
+ if obs['status']!='live':continue
418
+ key=user+':'+obs['room_id']
419
+ if key in d['events'] or any(h['session_key']==key for h in d['history']):continue
420
+ candidates=self.video_sources(obs['sources'])
421
+ if not candidates or not any(self._probe_ok(user,c) for c in candidates):continue
422
+ try:
423
+ self.ensure_stream(user)
424
+ if self.open_event(user,obs):active+=1
425
+ except Exception as e:self.log(user,self.safe_error(e))
426
+ # 3) Drive every open event independently.
427
+ for key in list(self.journal.read()['events'].keys()):
428
+ try:self.drive_event(key)
429
+ except Exception as e:self._event_error(key,e)
430
+ d=self.journal.read()
431
+ if capped:self.set_status('بلغنا الحد الأقصى للأحداث المتزامنة ('+str(cap)+')؛ حسابات إضافية تنتظر فراغها')
432
+ elif d['events']:self.set_status(str(len(d['events']))+' حدث/أحداث يوتيوب جارية')
433
+ else:self.set_status('انتظار بث قابل للإرسال من الحسابات المفعلة')
434
+ def drive_event(self,key):
435
+ ev=self.journal.read()['events'].get(key)
436
+ if not ev or ev.get('closing'):return
437
+ user=ev['username']
438
+ obs=self.observe(user)
439
+ if obs['status']=='offline':
440
+ self.stop_sender(key)
441
+ ev=self.journal.read()['events'].get(key)
442
+ if not ev:return
443
+ count=int(ev.get('offline_count') or 0)+1;since=ev.get('offline_since') or time.time()
444
+ self.event_update(key,offline_count=count,offline_since=since)
445
+ if count>=2 and time.time()-float(since)>=self.end_grace:self.finish_event(key)
446
+ else:self.event_note(key,'تأكيد انتهاء جلسة تيك توك؛ مهلة قبل إنهاء يوتيوب')
447
+ return
448
+ if obs['status']!='live':raise RuntimeError('حالة تيك توك غير مؤكدة؛ لا ننهي الحدث بسبب خطأ رصد')
449
+ if obs['room_id']!=ev['room_id']:
450
+ self.event_update(key,closing=True);self.finish_event(key);return
451
+ self.event_update(key,offline_since=None,offline_count=0)
452
+ # FIX C: clear sticky transient error note once TikTok is reachable again (recovery)
453
+ # Without this the 'التوقف عن الإنشاء لحماية الأحداث' message stayed 40 min.
454
+ if ev.get('note') and ('حماية الأحداث' in ev['note'] or 'خطأ داخلي' in ev['note'] or 'networkOrInvalidResponse' in ev['note']):
455
+ self._clear_stale_error_note(key)
456
+ ev=self.journal.read()['events'].get(key)
457
+ if not ev:return
458
+ if not self.video_sources(obs['sources']):
459
+ if not ev['was_live'] and time.time()-ev['created_at']>int(os.getenv('YOUTUBE_START_TIMEOUT','900')):
460
+ self.event_update(key,closing=True);self.finish_event(key)
461
+ else:self.event_note(key,'الجلسة مستمرة؛ انتظار المصدر المحدد للجودة')
462
+ return
463
+ if user not in self.destinations:
464
+ self.ensure_stream(user)
465
+ # recovery: stream succeeded after transient failure -> clear sticky note
466
+ ev=self.journal.read()['events'].get(key)
467
+ if ev and ev.get('note') and ('حماية الأحداث' in ev['note'] or 'خطأ داخلي' in ev['note']):
468
+ self._clear_stale_error_note(key)
469
+ ev=self.journal.read()['events'].get(key)
470
+ if not ev.get('bound'):
471
+ remote=self.ensure_broadcast(key)
472
+ if not remote:self.archive_event(key,'missing');return
473
+ ev=self.journal.read()['events'].get(key)
474
+ if not ev or not ev.get('bound'):
475
+ return
476
+ # FIX C: successful bind after transient TLS failure should clear the stuck note
477
+ if ev.get('note') and ('حماية الأحداث' in ev['note'] or 'خطأ داخلي' in ev['note']):
478
+ self._clear_stale_error_note(key)
479
+ ev=self.journal.read()['events'].get(key)
480
+ # FIX B: do NOT start sender here unconditionally. Fall through to gated check below
481
+ # which verifies YouTube lifecycle is ready/testing/live. This prevents the 187 storm
482
+ # that was caused by pushing to RTMPS before ingest existed (5:22 late, 116s waste).
483
+
484
+ # ---------- gated sender start (FIX B) ----------
485
+ ev=self.journal.read()['events'].get(key)
486
+ if not ev or not ev.get('broadcast_id') or not ev.get('bound'):
487
+ return
488
+ # Respect 45s poll interval for quota, but also gate sender on lifecycle status
489
+ # If we have a cached youtube_status we can gate without an extra API call
490
+ needs_poll = time.time()-float(ev.get('last_poll') or 0) >= 45
491
+ if needs_poll:
492
+ self.event_update(key,last_poll=time.time())
493
+ remote=self.remote('liveBroadcasts',ev['broadcast_id'])
494
+ if not remote:
495
+ self.event_update(key,closing=True);self.finish_event(key);return
496
+ if ev['marker'] not in remote.get('snippet',{}).get('description','').splitlines():
497
+ raise RuntimeError('تغيرت علامة الحدث؛ توقف آمن دون تعديل أحداث أخرى')
498
+ life=remote.get('status',{}).get('lifeCycleStatus');self.event_update(key,youtube_status=life)
499
+ ev=self.journal.read()['events'].get(key)
500
+ # FIX C: successful remote fetch means transport recovered -> clear stuck error
501
+ if ev.get('note') and ('حماية الأحداث' in ev['note'] or 'خطأ داخلي' in ev['note'] or 'network' in ev['note']):
502
+ self._clear_stale_error_note(key)
503
+ ev=self.journal.read()['events'].get(key)
504
+ else:
505
+ # Use cached status to gate sender without burning quota
506
+ life=ev.get('youtube_status')
507
+ # If status unknown or stale 'created', fetch once to avoid wrong gate
508
+ if not life or life == 'created':
509
+ remote=self.remote('liveBroadcasts',ev['broadcast_id'])
510
+ if not remote:
511
+ self.event_update(key,closing=True);self.finish_event(key);return
512
+ life=remote.get('status',{}).get('lifeCycleStatus');self.event_update(key,youtube_status=life)
513
+ ev=self.journal.read()['events'].get(key)
514
+ if ev.get('note') and ('حماية الأحداث' in ev['note'] or 'خطأ داخلي' in ev['note']):
515
+ self._clear_stale_error_note(key)
516
+ ev=self.journal.read()['events'].get(key)
517
+ else:
518
+ remote=None
519
+
520
+ if life in ('complete','revoked'):
521
+ self.event_update(key,closing=True);self.finish_event(key);return
522
+ if life=='live':
523
+ if not ev['was_live']:
524
+ self.event_update(key,was_live=True,phase='live')
525
+ self.log(user,'يوتيوب أكد أن الحدث مباشر: '+ev['broadcast_id'])
526
+ # Now safe to ensure sender is running; viewer already watching
527
+ self.start_sender(key)
528
+ self.stream_health(key,user,ev)
529
+ return
530
+
531
+ # life is expected to be 'ready' or 'testing' for a newly bound event
532
+ if life not in ('ready','testing'):
533
+ # 'created' etc. -> ingest not yet receivable, don't push (prevents 187)
534
+ if life == 'created':
535
+ self.event_note(key,'تم ربط الحدث؛ انتظار جاهزية الاستقبال قبل الإرسال')
536
+ else:
537
+ self.event_note(key,'انتظار تأكيد استقبال يوتيوب قبل بدء الحدث')
538
+ self.stop_sender(key)
539
+ return
540
+
541
+ # life is ready/testing -> we need to push so streamStatus becomes active, then transition
542
+ self.start_sender(key)
543
+
544
+ # Transition to live if YouTube already sees an active stream
545
+ # Only when we did a full poll; otherwise defer transition to next poll
546
+ if needs_poll:
547
+ stream_id=(self.journal.read()['streams'].get(user) or {}).get('id')
548
+ stream=self.remote('liveStreams',stream_id) if stream_id else None
549
+ if stream and stream.get('status',{}).get('streamStatus')=='active' and life in ('ready','testing'):
550
+ self.api.call('liveBroadcasts/transition','POST',{'part':'id,status','id':ev['broadcast_id'],'broadcastStatus':'live'})
551
+ self.event_update(key,phase='starting');self.event_note(key,'يوتيوب يستقبل؛ طُلب بدء الحدث للعامة حسب الخصوصية')
552
+ elif time.time()-ev['created_at']>int(os.getenv('YOUTUBE_START_TIMEOUT','900')) and not ev['was_live']:
553
+ self.event_update(key,closing=True);self.finish_event(key)
554
+ else:
555
+ # keep waiting note, but ensure error note is cleared
556
+ if ev.get('note') and ('حماية الأحداث' in ev['note'] or 'خطأ داخلي' in ev['note']):
557
+ self.event_update(key, note='انتظار تأكيد استقبال يوتيوب قبل بدء الحدث')
558
+ elif not ev.get('note'):
559
+ self.event_note(key,'انتظار تأكيد استقبال يوتيوب قبل بدء الحدث')
560
+ def stream_health(self,key,user,ev):
561
+ """Read YouTube's own verdict on the inbound stream. The Live Streaming API reports
562
+ exactly why viewers buffer (videoIngestionStarved), why the picture can be rejected
563
+ (videoResolutionUnsupported / videoCodec) and why playback stalls (gopSizeLong /
564
+ openGop). Surfacing it replaces guesswork with ground truth. Polled on a slower
565
+ cadence than the life-cycle check so it stays inside the quota budget."""
566
+ interval=max(60,int(os.getenv('YOUTUBE_HEALTH_INTERVAL','180')))
567
+ if time.time()-float(ev.get('last_health') or 0)<interval:return
568
+ stream_id=(self.journal.read()['streams'].get(user) or {}).get('id')
569
+ if not stream_id:return
570
+ self.event_update(key,last_health=time.time())
571
+ stream=self.remote('liveStreams',stream_id)
572
+ if not stream:return
573
+ health=stream.get('status',{}).get('healthStatus') or {}
574
+ issues=[]
575
+ for i in (health.get('configurationIssues') or []):
576
+ sev=i.get('severity','')
577
+ if sev in ('warning','error'):
578
+ issues.append('%s [%s] %s'%(i.get('type',''),sev,i.get('reason','')))
579
+ text=' | '.join(issues)
580
+ prev=ev.get('last_issues')
581
+ self.event_update(key,youtube_health=health.get('status',''),youtube_issues=text,last_issues=text)
582
+ if text and text!=prev:
583
+ self.log(user,'يوتيوب يشخّص البث: '+text)
584
+ def run_manager(self):
585
+ checked=False
586
+ try:
587
+ while not self.manager_stop.is_set():
588
+ delay=self.scan
589
+ try:
590
+ with self.operations:
591
+ if not checked:self.check_channel();checked=True
592
+ self.tick()
593
+ d=self.journal.read()
594
+ if not d['enabled'] and not d['events']:self.set_status('الأتمتة معطلة؛ لا إرسال ولا إنشاء أحداث')
595
+ except Exception as e:
596
+ text=self.safe_error(e)
597
+ if not isinstance(e,(APIError,RuntimeError,ValueError)):text+=' ('+type(e).__name__+')'
598
+ self.set_status(text)
599
+ if text!=self.last_logged:self.log('youtube',text);self.last_logged=text
600
+ if isinstance(e,APIError) and (e.status in (401,403,429) or e.reason=='invalid_grant'):delay=300
601
+ else:delay=60
602
+ self.manager_stop.wait(delay)
603
+ finally:
604
+ for key in list(self.senders):self.stop_sender(key)
605
+ self.set_status('مدير الأحداث متوقف؛ حالة الجلسات محفوظة')