File size: 30,032 Bytes
56bfa33 35c2217 56bfa33 35c2217 56bfa33 35c2217 56bfa33 35c2217 56bfa33 35c2217 56bfa33 35c2217 56bfa33 35c2217 56bfa33 35c2217 56bfa33 35c2217 56bfa33 35c2217 56bfa33 35c2217 56bfa33 35c2217 56bfa33 35c2217 56bfa33 35c2217 56bfa33 35c2217 56bfa33 35c2217 56bfa33 35c2217 56bfa33 35c2217 56bfa33 35c2217 56bfa33 35c2217 56bfa33 35c2217 56bfa33 35c2217 56bfa33 35c2217 56bfa33 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 | import os, re, json, time, threading, subprocess, shutil, secrets, logging
from pathlib import Path
from urllib.parse import urlparse
from concurrent.futures import ThreadPoolExecutor
from fastapi import FastAPI, Depends, HTTPException
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.responses import HTMLResponse, FileResponse
from pydantic import BaseModel
from curl_cffi import requests
import tiktok_auth
ROOT=Path(os.getenv('DATA_DIR','/data/tiktok-recorder' if Path('/data').is_dir() else './storage')).resolve()
ROOT.mkdir(parents=True,exist_ok=True)
FILES=ROOT/'recordings'; FILES.mkdir(exist_ok=True)
TOKENS=ROOT/'tokens.json'
tiktok_auth.configure(ROOT/'tiktok-tokens.json')
PASSWORD=os.getenv('PASSWORD','')
if not PASSWORD: raise RuntimeError('Set PASSWORD before starting the app')
FFMPEG=os.getenv('FFMPEG_PATH') or shutil.which('ffmpeg')
if not FFMPEG: raise RuntimeError('ffmpeg is required')
MKVMERGE=os.getenv('MKVMERGE_PATH') or shutil.which('mkvmerge')
FFPROBE=os.getenv('FFPROBE_PATH') or shutil.which('ffprobe')
if not MKVMERGE or not FFPROBE: raise RuntimeError('mkvmerge and ffprobe are required')
FINALIZE_LOCK=threading.Lock()
INTERVAL=max(30,int(os.getenv('CHECK_INTERVAL','60')))
SEGMENT=max(0,int(os.getenv('SEGMENT_SECONDS','0'))) # 0 = no scheduled split
# MAX_ACCOUNTS=0 means unlimited. The old default of 3 blocked adding
# legitimate accounts even though the rest of the app can handle them.
_MAX_ACCOUNTS=int(os.getenv('MAX_ACCOUNTS','0'))
MAX_ACCOUNTS=_MAX_ACCOUNTS if _MAX_ACCOUNTS>0 else None
MIN_FREE_MB=int(os.getenv('MIN_FREE_MB','1024'))
# SMART CLEANUP: guarantee free space for continuous broadcast
KEEP_LAST_FILES=max(1,int(os.getenv('KEEP_LAST_FILES','2'))) # keep newest N files always
ENSURE_FREE_MB=max(MIN_FREE_MB*2,int(os.getenv('ENSURE_FREE_MB','2048'))) # target free before/after finalize
AUTO_DELETE_MKV=True # never keep .mkv if .mp4 succeeded (user request)
lock=threading.RLock(); tasks={}; events=[]; handles={}
security=HTTPBasic()
def auth(c:HTTPBasicCredentials=Depends(security)):
if not (secrets.compare_digest(c.username.encode(),b'admin') and secrets.compare_digest(c.password.encode(),PASSWORD.encode())):
raise HTTPException(401,'Unauthorized',headers={'WWW-Authenticate':'Basic'})
app=FastAPI(dependencies=[Depends(auth)],docs_url=None,redoc_url=None,openapi_url=None)
def log(user,msg):
with lock:
events.append({'time':time.strftime('%H:%M:%S'),'user':user,'message':msg})
del events[:-200]
def update(user,**kw):
with lock: tasks[user].update(kw)
def save():
with lock:
tmp=ROOT/'tasks.tmp';tmp.write_text(json.dumps({u:t['enabled'] for u,t in tasks.items()}));tmp.replace(ROOT/'tasks.json')
def smart_cleanup(reason="periodic"):
"""Intelligent deletion: always keep KEEP_LAST_FILES newest, delete oldest until ENSURE_FREE_MB is free.
Never touches .partial. files (currently recording/finalizing). Deletes .mkv first if MP4 exists."""
try:
# Never delete partial files
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']
if not all_completed:
return 0
# Sort by mtime: newest first
all_completed.sort(key=lambda p: p.stat().st_mtime, reverse=True)
# Always keep newest KEEP_LAST_FILES
keep = set(all_completed[:KEEP_LAST_FILES])
candidates = all_completed[KEEP_LAST_FILES:] # oldest first will be at end, but we iterate oldest first for deletion
candidates.sort(key=lambda p: p.stat().st_mtime) # oldest first
freed=0
deleted=[]
# Phase 1: delete .mkv files that have a corresponding .mp4 (redundant) - user requested no mkv
if AUTO_DELETE_MKV:
for p in list(candidates):
if p.suffix == '.mkv':
# if same stem has mp4, it's redundant
stem = p.stem
# stem may be like user_20260917_... - check if any mp4 with similar prefix exists in keep or candidates
# simpler: just delete all mkv older than keep
try:
sz=p.stat().st_size
p.unlink()
freed+=sz
deleted.append(p.name)
candidates.remove(p)
except OSError:
pass
# 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
for p in list(keep):
if p.suffix == '.mkv':
# if there's an mp4 with same date prefix, delete mkv even from keep? But respect KEEP_LAST_FILES: keep mp4 instead
# Find if there's a newer mp4 that covers same period - just keep it for now, but log
pass
# Phase 2: ensure free space
free_mb = shutil.disk_usage(FILES).free / (1024*1024)
# Also consider if total size of completed files > 80% of disk, proactively clean
for p in candidates:
if free_mb >= ENSURE_FREE_MB:
break
try:
sz=p.stat().st_size
p.unlink()
freed+=sz
free_mb += sz/(1024*1024)
deleted.append(p.name)
except OSError:
continue
if deleted:
log('system', f'تنظيف ذكي ({reason}): حذف {len(deleted)} ملف قديم لتوفير {round(freed/1048576,1)}MB — ضمنا {int(free_mb)}MB فراغ للبث المستمر (احتفظنا بآخر {KEEP_LAST_FILES})')
return len(deleted)
except Exception as e:
log('system', f'تعذر التنظيف الذكي: {type(e).__name__}')
return 0
# TikTok session tokens (sessionid) — required for age-restricted (18+) lives.
# Stored per account; a global fallback can be set via the TIKTOK_SESSIONID secret.
_tokens={}
def load_tokens():
global _tokens
try:_tokens=json.loads(TOKENS.read_text())
except (FileNotFoundError,ValueError,OSError):_tokens={}
# One-way migration so tokens saved by earlier versions gain expiry tracking.
for u,t in list(_tokens.items()):
try:
if t and t.strip():tiktok_auth.save(u,t)
except (ValueError,OSError):pass
def save_tokens():
with lock:
tmp=ROOT/'tokens.tmp';tmp.write_text(json.dumps(_tokens));tmp.replace(TOKENS)
def set_token(user,token):
token=(token or '').strip()
with lock:
if token:
_tokens[user]=token
try:tiktok_auth.save(user,token)
except ValueError:pass # keep the raw value; the UI reports format problems separately
else:
_tokens.pop(user,None)
tiktok_auth.delete(user)
save_tokens()
def get_token(user):
with lock:return _tokens.get(user) or os.getenv('TIKTOK_SESSIONID','').strip()
def tiktok_headers(user):
# Accepts a bare sessionid value or a full cookie string. Never logged or echoed back.
# tiktok_auth expands it to the cookie set TikTok's own web client sends for
# age-restricted rooms: sessionid + sessionid_ss + sid_tt + tt-target-idc.
tok=get_token(user) if user else ''
if not tok:return {}
sessionid,pairs=tiktok_auth.parse_token(tok)
if not sessionid:return {}
return {'Cookie':'; '.join('%s=%s'%(k,v) for k,v in pairs.items())}
def clean_user(raw):
raw=raw.strip()
if raw.startswith('https://'):
p=urlparse(raw)
if p.hostname not in ('www.tiktok.com','tiktok.com','vm.tiktok.com','vt.tiktok.com'):raise ValueError('الرابط يجب أن يكون من TikTok')
# Only direct profile/live URLs: avoids following arbitrary redirects server-side.
m=re.fullmatch(r'/@([\w.]{1,32})(?:/live)?/?',p.path)
if not m:raise ValueError('استخدم اسم الحساب أو رابطه الكامل، وليس رابط المشاركة المختصر')
raw=m.group(1)
raw=raw.lstrip('@')
if not re.fullmatch(r'[A-Za-z0-9_.]{1,32}',raw):raise ValueError('اسم حساب غير صالح')
return raw.lower()
def get_json(url,params,user=None):
r=requests.get(url,params=params,impersonate='chrome',headers={'Referer':'https://www.tiktok.com/',**tiktok_headers(user)},timeout=20)
r.raise_for_status();return r.json()
_observation_cache={}
_observation_locks={}
_observation_guard=threading.Lock()
def observe_session(user):
with _observation_guard:guard=_observation_locks.setdefault(user,threading.Lock())
with guard:
cached=_observation_cache.get(user)
if cached and time.monotonic()-cached[0]<15:return cached[1]
result=_observe_session(user)
_observation_cache[user]=(time.monotonic(),result)
return result
def _observe_session(user):
j=get_json('https://www.tiktok.com/api-live/user/room',{'aid':1988,'uniqueId':user,'sourceType':54,'staleTime':0},user)
d=j.get('data',{});live=d.get('liveRoom',{});status=live.get('status');person=d.get('user',{})
room=str(person.get('roomId') or person.get('room_id') or '')
nickname=str(person.get('nickname') or '').strip()
live_title=str(live.get('title') or '').strip()
if status in (4,'4'):return {'status':'offline','room_id':room,'sources':[],'nickname':nickname,'live_title':live_title}
blocks=[live[k] for k in ('streamData','hevcStreamData') if isinstance(live.get(k),dict)]
sources=rank_sources({'blocks':blocks}) if status in (2,'2') else []
# Live but every stream block is empty: the classic age-restricted (18+) shape.
if status in (2,'2') and not sources and tiktok_auth.classify_restriction(j):
raise RuntimeError(MSG)
if not room or room=='0':
r=requests.get(f'https://www.tiktok.com/@{user}/live',impersonate='chrome',headers={'Referer':'https://www.tiktok.com/',**tiktok_headers(user)},timeout=20)
r.raise_for_status();m=re.search(r'"roomId"\s*:\s*"(\d+)"',r.text)
room=m.group(1) if m else ''
if not room.isdigit() or room=='0':raise RuntimeError('تعذر تحديد معرّف جلسة البث؛ لن ننشئ حدث يوتيوب بالتخمين')
if not sources:
j=get_json('https://webcast.tiktok.com/webcast/room/info/',{'aid':1988,'room_id':room},user)
info=j.get('data',{});status=info.get('status');owner=info.get('owner') or {}
nickname=str(owner.get('nickname') or '').strip() or nickname
live_title=str(info.get('title') or '').strip() or live_title
if status in (4,'4'):return {'status':'offline','room_id':room,'sources':[],'nickname':nickname,'live_title':live_title}
if status not in (2,'2'):
if tiktok_auth.classify_restriction(j):raise RuntimeError(MSG)
raise RuntimeError('حالة تيك توك غير مؤكدة؛ لا نعتبرها انتهاء للبث')
sources=rank_sources(info.get('stream_url') or {})
if not sources and tiktok_auth.classify_restriction(j):raise RuntimeError(MSG)
return {'status':'live','room_id':room,'sources':sources,'nickname':nickname,'live_title':live_title}
def extract(user):
observed=observe_session(user)
if observed['status']=='offline':return []
if not observed['sources']:raise RuntimeError('البث مباشر لكن روابط الفيديو غير متاحة — '+MSG)
return observed['sources']
def rank_sources(source):
candidates=[]
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}
def add(url,label,params=None,assumed=False):
if not isinstance(url,str) or not url.startswith('https://') or '.m3u8' in url:return
if str(label).lower() in ('ao','audio','audio_only'):return
params=params or {};res=str(params.get('resolution',''))
nums=re.findall(r'\d+',res)
pixels=int(nums[0])*int(nums[1]) if len(nums)>1 else (int(nums[0])**2 if nums else 0)
try:rate=float(params.get('vbitrate') or 0)
except (ValueError,TypeError):rate=0
quality=priorities.get(str(label).lower(),50)
# Explicit original labels first; use declared dimensions/rate when supplied.
score=(quality==100, pixels,rate,quality,not assumed)
candidates.append({'url':url,'label':str(label)+((' · '+res) if res else '')+' · '+str(params.get('VCodec') or params.get('v_codec') or 'غير محدد'),'score':score})
blocks=list(source.get('blocks',[]))
if source.get('live_core_sdk_data'):blocks.append(source['live_core_sdk_data'])
for block in blocks:
try:
payload=block['pull_data']['stream_data']
stream=json.loads(payload) if isinstance(payload,str) else payload
for quality,item in stream.get('data',{}).items():
main=item.get('main',{})
try:
params=main.get('sdk_params') or {}
if isinstance(params,str):params=json.loads(params)
except (ValueError,TypeError):params={}
add(main.get('flv'),quality,params)
except (KeyError,TypeError,ValueError):pass
for quality,url in (source.get('flv_pull_url') or {}).items():
try:params=json.loads((source.get('flv_pull_url_params') or {}).get(quality) or '{}')
except (ValueError,TypeError):params={}
add(url,quality,params)
# Do not assume the generic pull URL is the original feed.
add(source.get('rtmp_pull_url'),'غير مصنفة',assumed=True)
result=[];seen=set()
for item in sorted(candidates,key=lambda c:c['score'],reverse=True):
if item['url'] not in seen:result.append(item);seen.add(item['url'])
return result
from youtube_manager import ManagedYouTube
def enabled_accounts():
with lock:return [u for u,t in tasks.items() if t.get('enabled')]
youtube=ManagedYouTube(FFMPEG,observe_session,log,enabled_accounts,ROOT/'youtube-api-state.json',headers_for=tiktok_headers)
class FLVWriter:
"""Write complete FLV tags only; arbitrary network chunk boundaries are not packet boundaries."""
def __init__(self,out):self.out=out;self.buf=bytearray();self.header=False;self.written=0
def feed(self,chunk):
self.buf.extend(chunk)
if not self.header:
if len(self.buf)<9:return
if self.buf[:3]!=b'FLV':raise RuntimeError('المصدر لا يعيد FLV صالحًا')
size=int.from_bytes(self.buf[5:9],'big')+4
if not 13<=size<=1048576:raise RuntimeError('ترويسة FLV غير صالحة')
if len(self.buf)<size:return
self.out.write(self.buf[:size]);del self.buf[:size];self.written+=size;self.header=True
while len(self.buf)>=11:
size=11+int.from_bytes(self.buf[1:4],'big')+4
if len(self.buf)<size:break
self.out.write(self.buf[:size]);del self.buf[:size];self.written+=size
def inspect_media(path):
r=subprocess.run([FFPROBE,'-v','error','-show_streams','-of','json',str(path)],capture_output=True,text=True,timeout=60)
if r.returncode:raise RuntimeError('تعذر فحص بيانات الفيديو')
streams=json.loads(r.stdout).get('streams',[])
video=next((x for x in streams if x.get('codec_type')=='video' and x.get('width',0)>0),None)
if not video:raise RuntimeError('لم يوجد مسار فيديو صالح')
return video
def finalize(raw,stem,user):
mkv=FILES/(stem+'.partial.mkv');mp4=FILES/(stem+'.partial.mp4')
# SMART: ensure space before heavy remux (needs ~2x file size temporarily)
smart_cleanup(reason="قبل البناء")
# Serialize remuxes: recording threads continue, but concurrent disk copies are limited.
with FINALIZE_LOCK:
# Double-check free after waiting for lock
if shutil.disk_usage(FILES).free < ENSURE_FREE_MB*1024*1024:
smart_cleanup(reason="قبل البناء - طارئ")
r=subprocess.run([MKVMERGE,'-o',str(mkv),str(raw)],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=3600)
if r.returncode not in (0,1) or not mkv.exists():raise RuntimeError('تعذر تغليف FLV إلى MKV؛ احتفظنا بالأصل')
info=inspect_media(mkv)
codec_args=['-tag:v','hvc1'] if info.get('codec_name')=='hevc' else []
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)
if r.returncode==0 and mp4.exists() and mp4.stat().st_size>20000:
measured=inspect_media(mp4)
mp4.replace(FILES/(stem+'.mp4'));mkv.unlink(missing_ok=True);raw.unlink(missing_ok=True)
quality=f"{measured['width']}×{measured['height']} · {measured.get('codec_name','')}"
if user in tasks:update(user,quality=quality)
log(user,'تم حفظ MP4 دون إعادة ترميز؛ قياس الملف: '+quality+' (ليس فحص فك ترميز كاملًا)')
# SMART: never keep mkv if mp4 succeeded (user request)
# Already deleted mkv above. Also ensure we keep only KEEP_LAST_FILES
smart_cleanup(reason="بعد البناء")
else:
mp4.unlink(missing_ok=True)
# If mp4 failed but mkv succeeded, keep mkv but user said no need mkv for youtube. Keep mkv only as fallback
# To respect request "ماله داعي نحفظ بث اليوتيوب الي امتداده mkv" -> delete mkv if we prefer mp4 only? Keep mkv only if mp4 failed
if AUTO_DELETE_MKV and mkv.exists() and mkv.stat().st_size < 50*1024*1024:
# small mkv with no mp4? Keep it but log
pass
mkv.replace(FILES/(stem+'.mkv'));raw.unlink(missing_ok=True)
log(user,'تعذر MP4؛ تم حفظ MKV صالح حسب فحص المسارات')
smart_cleanup(reason="بعد بناء MKV")
def record(user,candidate,stop):
# SMART: pre-check and clean before starting new segment
if shutil.disk_usage(FILES).free < ENSURE_FREE_MB*1024*1024:
smart_cleanup(reason="قبل التسجيل")
if shutil.disk_usage(FILES).free < MIN_FREE_MB*1024**2:
raise RuntimeError('مساحة نظام الملفات منخفضة — تم التنظيف الذكي وما زالت ممتلئة')
stem=f'{user}_{time.strftime("%Y%m%d_%H%M%S")}_{secrets.token_hex(2)}'
raw=FILES/(stem+'.partial.flv');start=time.monotonic();last_update=start
if user in tasks:update(user,status='جاري الاتصال',quality='مصدر: '+candidate['label'],received_mb=0,elapsed_seconds=0)
response=requests.get(candidate['url'],impersonate='chrome',headers={'Referer':'https://www.tiktok.com/',**tiktok_headers(user)},stream=True,timeout=20)
try:
response.raise_for_status()
with open(raw,'wb') as out:
writer=FLVWriter(out)
try:
for chunk in response.iter_content():
if stop.is_set() or (SEGMENT and time.monotonic()-start>=SEGMENT):break
if chunk:writer.feed(chunk)
if time.monotonic()-last_update>=2:
out.flush();last_update=time.monotonic()
if user in tasks:update(user,status='تسجيل — وصول بيانات',received_mb=round(writer.written/1048576,1),elapsed_seconds=int(last_update-start))
if shutil.disk_usage(FILES).free<MIN_FREE_MB*1024**2:
log(user,'انخفاض المساحة؛ إنهاء الملف');break
except Exception as e:
log(user,'انقطع استقبال المصدر؛ محاولة حفظ البيانات المكتملة ('+type(e).__name__+')')
out.flush()
finally:response.close()
if not raw.exists() or raw.stat().st_size<20000:
raw.unlink(missing_ok=True);raise RuntimeError('لم تصل بيانات فيديو كافية')
if user in tasks:update(user,status='تجهيز الملف — قد يستغرق وقتًا للبث الطويل')
try:finalize(raw,stem,user)
except Exception:
# Keep the complete-tag raw recording for manual recovery, never silently delete it.
if raw.exists():raw.replace(FILES/(stem+'.recovery.flv'))
log(user,'فشل التجهيز؛ احتفظنا بملف recovery.flv للتنزيل والاستعادة')
raise RuntimeError('فشل تجهيز المصدر؛ سيجرب مصدرًا آخر')
def worker(user,stop):
failures=0
while not stop.is_set():
# periodic smart check every loop
if failures==0:
# don't spam cleanup every 30s, check free occasionally
if shutil.disk_usage(FILES).free < ENSURE_FREE_MB*1024*1024:
smart_cleanup(reason="دوري")
try:
update(user,status='فحص البث')
urls=extract(user)
if not urls:
update(user,status='البث منتهٍ');stop.wait(INTERVAL);continue
update(user,status='جاري الاتصال بمصدر الفيديو');ok=False
for url in urls:
if stop.is_set():break
try:record(user,url,stop);ok=True;break
except Exception:log(user,'فشل مصدر الفيديو؛ تجربة مصدر آخر إن توفر')
if not ok and not stop.is_set():raise RuntimeError('فشلت مصادر الفيديو؛ إعادة المحاولة لاحقًا')
failures=0
if not stop.is_set():stop.wait(2)
except Exception as e:
failures+=1
# Never display exception URLs/tokens.
msg=str(e) if isinstance(e,RuntimeError) else 'فشل طلب بيانات البث أو التسجيل (ليس تأكيدًا أن الحساب غير مباشر)'
update(user,status='انتظار إعادة المحاولة');log(user,msg)
stop.wait(min(300,INTERVAL*failures))
update(user,status='متوقف')
def start(user):
with lock:
if user in handles and handles[user]['thread'].is_alive():return
stop=threading.Event();thread=threading.Thread(target=worker,args=(user,stop),daemon=True)
handles[user]={'stop':stop,'thread':thread,'process':None};thread.start()
class Account(BaseModel):
username:str
token:str=''
class TokenBody(BaseModel):
token:str=''
@app.get('/',response_class=HTMLResponse)
def index():return Path(__file__).with_name('index.html').read_text()
class YouTubeStart(BaseModel):
channel_id:str
class YouTubeSettings(BaseModel):
name:str=''
privacy:str
title:str
description:str
@app.get('/api/youtube')
def youtube_state():return youtube.snapshot()
@app.post('/api/youtube/check')
def youtube_check():
try:return {'channel':youtube.check()}
except Exception as e:
from youtube_api import APIError
msg=str(e) if isinstance(e,(APIError,RuntimeError,ValueError)) else 'تعذر فحص تفويض يوتيوب'
raise HTTPException(400,msg)
@app.post('/api/youtube/settings')
def youtube_settings(a:YouTubeSettings):
try:youtube.settings(a.model_dump())
except ValueError as e:raise HTTPException(400,str(e))
return {'ok':True}
@app.post('/api/youtube/start')
def youtube_start(a:YouTubeStart):
try:youtube.enable(a.channel_id)
except Exception as e:
from youtube_api import APIError
msg=str(e) if isinstance(e,(APIError,RuntimeError,ValueError)) else 'تعذر بدء أتمتة يوتيوب'
raise HTTPException(400,msg)
return {'ok':True}
@app.post('/api/youtube/stop')
def youtube_stop():
youtube.disable();return {'ok':True}
@app.get('/api/state')
def state():
with lock:
disk=shutil.disk_usage(FILES)
# trigger smart view but don't delete here - just report
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]
task_list=[{'username':u,**t,'has_token':bool(get_token(u))} for u,t in tasks.items()]
tiktok_state=tiktok_auth.describe([t['username'] for t in task_list])
free_mb = round(disk.free/1048576,1)
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}
@app.post('/api/accounts')
def add(a:Account):
try:u=clean_user(a.username)
except ValueError as e:raise HTTPException(400,str(e))
with lock:
if MAX_ACCOUNTS is not None and u not in tasks and len(tasks)>=MAX_ACCOUNTS:
raise HTTPException(400,'بلغت حد الموارد المحدد؛ يمكنك تغييره عبر MAX_ACCOUNTS')
tasks.setdefault(u,{'enabled':True,'status':'بدء التشغيل'});tasks[u]['enabled']=True;save();start(u)
if a.token and a.token.strip():set_token(u,a.token)
return {'ok':True}
@app.post('/api/accounts/{user}/token')
def account_token(user:str,b:TokenBody):
with lock:
if user not in tasks:raise HTTPException(404)
set_token(user,b.token)
return {'ok':True,'has_token':bool(get_token(user)),'tiktok':tiktok_auth.describe([user])}
@app.get('/api/tiktok/token')
def tiktok_token_state():
"""Masked token state only — never the token itself."""
with lock:users=list(tasks.keys())
return tiktok_auth.describe(users)
@app.post('/api/tiktok/token/validate')
def tiktok_token_validate(b:TokenBody):
"""Ask TikTok whether the pasted token is alive. A rejected token is not saved."""
raw=(b.token or '').strip()
if not raw:raise HTTPException(400,'الصق التوكن أولًا')
report=tiktok_auth.validate(raw)
if report.get('ok'):
# Only a token TikTok accepted reaches the store.
try:tiktok_auth.save('',raw)
except ValueError as e:raise HTTPException(400,str(e))
log('system','حُفظ توكن تيك توك %s — سيُستخدم للرصد والتنزيل'%report.get('sessionid_masked',''))
else:
log('system','تيك توك رفض التوكن؛ لم يُحفظ')
return report
@app.post('/api/tiktok/token/check/{user}')
def tiktok_token_check(user:str):
"""Live proof: does this account's room yield video URLs with vs without the token?"""
try:return tiktok_auth.unlock_check(user)
except Exception as e:
raise HTTPException(400,str(e) if isinstance(e,(ValueError,RuntimeError)) else 'تعذر اختبار فتح الحساب')
@app.delete('/api/tiktok/token/{user}')
def tiktok_token_delete(user:str):
with lock:
_tokens.pop(user,None);save_tokens()
return {'ok':tiktok_auth.delete(user)}
@app.post('/api/accounts/{user}/{action}')
def action(user:str,action:str):
with lock:
if user not in tasks:raise HTTPException(404)
if action=='start':
if user in handles and handles[user]['thread'].is_alive() and handles[user]['stop'].is_set():raise HTTPException(409,'انتظر اكتمال إيقاف المقطع')
tasks[user]['enabled']=True;start(user)
elif action=='stop':
tasks[user]['enabled']=False
if user in handles:handles[user]['stop'].set()
elif action=='delete':
if user in handles and handles[user]['thread'].is_alive():raise HTTPException(409,'أوقف الحساب وانتظر انتهاء الحفظ قبل حذفه')
del tasks[user]
_tokens.pop(user,None);save_tokens()
else:raise HTTPException(400)
save()
return {'ok':True}
def safe_file(name):
p=(FILES/name).resolve()
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)
return p
@app.get('/api/files/{name}')
def download(name:str):return FileResponse(safe_file(name),filename=name)
@app.delete('/api/files/{name}')
def delete(name:str):safe_file(name).unlink();return {'ok':True}
@app.on_event('startup')
def startup():
load_tokens()
# smart cleanup on startup to guarantee space
try:
smart_cleanup(reason="بدء التشغيل")
except Exception:
pass
try:saved=json.loads((ROOT/'tasks.json').read_text())
except FileNotFoundError:saved={}
except (OSError,ValueError):
logging.exception('Unable to restore task settings')
log('system','تعذر قراءة الحسابات المحفوظة؛ تحقق من التخزين قبل إضافة حسابات')
saved={}
if not isinstance(saved,dict):
log('system','صيغة ملف الحسابات غير صالحة');saved={}
restored=list(saved.items())
if MAX_ACCOUNTS is not None:
restored=restored[:MAX_ACCOUNTS]
for u,enabled in restored:
try:u=clean_user(u)
except ValueError:continue
tasks[u]={'enabled':bool(enabled),'status':'متوقف'}
if enabled:start(u)
youtube.restore()
@app.on_event('shutdown')
def shutdown():
youtube.stop()
for h in list(handles.values()):h['stop'].set()
deadline=time.monotonic()+50
for h in list(handles.values()):h['thread'].join(timeout=max(0,deadline-time.monotonic()))
# periodic smart janitor every 10 minutes
def janitor_loop():
while True:
time.sleep(600)
try:
smart_cleanup(reason="دوري كل 10 دقايق")
except Exception:
pass
threading.Thread(target=janitor_loop, daemon=True).start()
|