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()