purge all local-recording remnants: files endpoints/UI, smart_cleanup/janitor, recordings dir, mkvmerge dep — broadcast-only
Browse files- Dockerfile +1 -1
- app.py +4 -101
- index.html +1 -3
Dockerfile
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
FROM python:3.11-slim-trixie
|
| 2 |
-
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg
|
| 3 |
RUN ffmpeg -hide_banner -h protocol=rtmps 2>&1 | grep -q rtmp_enhanced_codecs
|
| 4 |
WORKDIR /app
|
| 5 |
COPY requirements.txt .
|
|
|
|
| 1 |
FROM python:3.11-slim-trixie
|
| 2 |
+
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg 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 .
|
app.py
CHANGED
|
@@ -1,38 +1,27 @@
|
|
| 1 |
-
import os, re, json, time, threading,
|
| 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
|
| 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)):
|
|
@@ -50,68 +39,6 @@ def save():
|
|
| 50 |
slim={u:{'enabled':bool(t.get('enabled')),'rtmp':t.get('rtmp') or ''} for u,t in tasks.items()}
|
| 51 |
tmp=ROOT/'tasks.tmp';tmp.write_text(json.dumps(slim));tmp.replace(ROOT/'tasks.json')
|
| 52 |
|
| 53 |
-
def smart_cleanup(reason="periodic"):
|
| 54 |
-
"""Intelligent deletion: always keep KEEP_LAST_FILES newest, delete oldest until ENSURE_FREE_MB is free.
|
| 55 |
-
Never touches .partial. files (currently recording/finalizing). Deletes .mkv first if MP4 exists."""
|
| 56 |
-
try:
|
| 57 |
-
# Never delete partial files
|
| 58 |
-
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']
|
| 59 |
-
if not all_completed:
|
| 60 |
-
return 0
|
| 61 |
-
# Sort by mtime: newest first
|
| 62 |
-
all_completed.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
| 63 |
-
# Always keep newest KEEP_LAST_FILES
|
| 64 |
-
keep = set(all_completed[:KEEP_LAST_FILES])
|
| 65 |
-
candidates = all_completed[KEEP_LAST_FILES:] # oldest first will be at end, but we iterate oldest first for deletion
|
| 66 |
-
candidates.sort(key=lambda p: p.stat().st_mtime) # oldest first
|
| 67 |
-
freed=0
|
| 68 |
-
deleted=[]
|
| 69 |
-
# Phase 1: delete .mkv files that have a corresponding .mp4 (redundant) - user requested no mkv
|
| 70 |
-
if AUTO_DELETE_MKV:
|
| 71 |
-
for p in list(candidates):
|
| 72 |
-
if p.suffix == '.mkv':
|
| 73 |
-
# if same stem has mp4, it's redundant
|
| 74 |
-
stem = p.stem
|
| 75 |
-
# stem may be like user_20260917_... - check if any mp4 with similar prefix exists in keep or candidates
|
| 76 |
-
# simpler: just delete all mkv older than keep
|
| 77 |
-
try:
|
| 78 |
-
sz=p.stat().st_size
|
| 79 |
-
p.unlink()
|
| 80 |
-
freed+=sz
|
| 81 |
-
deleted.append(p.name)
|
| 82 |
-
candidates.remove(p)
|
| 83 |
-
except OSError:
|
| 84 |
-
pass
|
| 85 |
-
# 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
|
| 86 |
-
for p in list(keep):
|
| 87 |
-
if p.suffix == '.mkv':
|
| 88 |
-
# if there's an mp4 with same date prefix, delete mkv even from keep? But respect KEEP_LAST_FILES: keep mp4 instead
|
| 89 |
-
# Find if there's a newer mp4 that covers same period - just keep it for now, but log
|
| 90 |
-
pass
|
| 91 |
-
|
| 92 |
-
# Phase 2: ensure free space
|
| 93 |
-
free_mb = shutil.disk_usage(FILES).free / (1024*1024)
|
| 94 |
-
# Also consider if total size of completed files > 80% of disk, proactively clean
|
| 95 |
-
for p in candidates:
|
| 96 |
-
if free_mb >= ENSURE_FREE_MB:
|
| 97 |
-
break
|
| 98 |
-
try:
|
| 99 |
-
sz=p.stat().st_size
|
| 100 |
-
p.unlink()
|
| 101 |
-
freed+=sz
|
| 102 |
-
free_mb += sz/(1024*1024)
|
| 103 |
-
deleted.append(p.name)
|
| 104 |
-
except OSError:
|
| 105 |
-
continue
|
| 106 |
-
if deleted:
|
| 107 |
-
log('system', f'تنظيف ذكي ({reason}): حذف {len(deleted)} ملف قديم لتوفير {round(freed/1048576,1)}MB — ضمنا {int(free_mb)}MB فراغ للبث المستمر (احتفظنا بآخر {KEEP_LAST_FILES})')
|
| 108 |
-
return len(deleted)
|
| 109 |
-
except Exception as e:
|
| 110 |
-
log('system', f'تعذر التنظيف الذكي: {type(e).__name__}')
|
| 111 |
-
return 0
|
| 112 |
-
# TikTok session tokens (sessionid) — required for age-restricted (18+) lives.
|
| 113 |
-
# Stored per account; a global fallback can be set via the TIKTOK_SESSIONID secret.
|
| 114 |
-
_tokens={}
|
| 115 |
def load_tokens():
|
| 116 |
global _tokens
|
| 117 |
try:_tokens=json.loads(TOKENS.read_text())
|
|
@@ -342,16 +269,14 @@ def youtube_stop():
|
|
| 342 |
@app.get('/api/state')
|
| 343 |
def state():
|
| 344 |
with lock:
|
| 345 |
-
disk=shutil.disk_usage(
|
| 346 |
-
# trigger smart view but don't delete here - just report
|
| 347 |
-
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]
|
| 348 |
# Never leak the RTMP destination; expose presence only.
|
| 349 |
task_list=[{'username':u,'enabled':t.get('enabled'),'status':t.get('status'),
|
| 350 |
'quality':t.get('quality'),'has_token':bool(get_token(u)),
|
| 351 |
'has_rtmp':bool(t.get('rtmp'))} for u,t in tasks.items()]
|
| 352 |
tiktok_state=tiktok_auth.describe([t['username'] for t in task_list])
|
| 353 |
free_mb = round(disk.free/1048576,1)
|
| 354 |
-
return {'tasks':task_list,'logs':events[-60:],'
|
| 355 |
@app.post('/api/accounts')
|
| 356 |
def add(a:Account):
|
| 357 |
try:u=clean_user(a.username)
|
|
@@ -426,22 +351,9 @@ def action(user:str,action:str):
|
|
| 426 |
else:raise HTTPException(400)
|
| 427 |
save()
|
| 428 |
return {'ok':True}
|
| 429 |
-
def safe_file(name):
|
| 430 |
-
p=(FILES/name).resolve()
|
| 431 |
-
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)
|
| 432 |
-
return p
|
| 433 |
-
@app.get('/api/files/{name}')
|
| 434 |
-
def download(name:str):return FileResponse(safe_file(name),filename=name)
|
| 435 |
-
@app.delete('/api/files/{name}')
|
| 436 |
-
def delete(name:str):safe_file(name).unlink();return {'ok':True}
|
| 437 |
@app.on_event('startup')
|
| 438 |
def startup():
|
| 439 |
load_tokens()
|
| 440 |
-
# smart cleanup on startup to guarantee space
|
| 441 |
-
try:
|
| 442 |
-
smart_cleanup(reason="بدء التشغيل")
|
| 443 |
-
except Exception:
|
| 444 |
-
pass
|
| 445 |
try:saved=json.loads((ROOT/'tasks.json').read_text())
|
| 446 |
except FileNotFoundError:saved={}
|
| 447 |
except (OSError,ValueError):
|
|
@@ -470,12 +382,3 @@ def shutdown():
|
|
| 470 |
deadline=time.monotonic()+50
|
| 471 |
for h in list(handles.values()):h['thread'].join(timeout=max(0,deadline-time.monotonic()))
|
| 472 |
|
| 473 |
-
# periodic smart janitor every 10 minutes
|
| 474 |
-
def janitor_loop():
|
| 475 |
-
while True:
|
| 476 |
-
time.sleep(600)
|
| 477 |
-
try:
|
| 478 |
-
smart_cleanup(reason="دوري كل 10 دقايق")
|
| 479 |
-
except Exception:
|
| 480 |
-
pass
|
| 481 |
-
threading.Thread(target=janitor_loop, daemon=True).start()
|
|
|
|
| 1 |
+
import os, re, json, time, threading, 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
|
| 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 |
TOKENS=ROOT/'tokens.json'
|
| 15 |
tiktok_auth.configure(ROOT/'tiktok-tokens.json')
|
| 16 |
PASSWORD=os.getenv('PASSWORD','')
|
| 17 |
if not PASSWORD: raise RuntimeError('Set PASSWORD before starting the app')
|
| 18 |
FFMPEG=os.getenv('FFMPEG_PATH') or shutil.which('ffmpeg')
|
| 19 |
if not FFMPEG: raise RuntimeError('ffmpeg is required')
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
INTERVAL=max(30,int(os.getenv('CHECK_INTERVAL','60')))
|
|
|
|
| 21 |
# MAX_ACCOUNTS=0 means unlimited. The old default of 3 blocked adding
|
| 22 |
# legitimate accounts even though the rest of the app can handle them.
|
| 23 |
_MAX_ACCOUNTS=int(os.getenv('MAX_ACCOUNTS','0'))
|
| 24 |
MAX_ACCOUNTS=_MAX_ACCOUNTS if _MAX_ACCOUNTS>0 else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
lock=threading.RLock(); tasks={}; events=[]; handles={}
|
| 26 |
security=HTTPBasic()
|
| 27 |
def auth(c:HTTPBasicCredentials=Depends(security)):
|
|
|
|
| 39 |
slim={u:{'enabled':bool(t.get('enabled')),'rtmp':t.get('rtmp') or ''} for u,t in tasks.items()}
|
| 40 |
tmp=ROOT/'tasks.tmp';tmp.write_text(json.dumps(slim));tmp.replace(ROOT/'tasks.json')
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
def load_tokens():
|
| 43 |
global _tokens
|
| 44 |
try:_tokens=json.loads(TOKENS.read_text())
|
|
|
|
| 269 |
@app.get('/api/state')
|
| 270 |
def state():
|
| 271 |
with lock:
|
| 272 |
+
disk=shutil.disk_usage(ROOT)
|
|
|
|
|
|
|
| 273 |
# Never leak the RTMP destination; expose presence only.
|
| 274 |
task_list=[{'username':u,'enabled':t.get('enabled'),'status':t.get('status'),
|
| 275 |
'quality':t.get('quality'),'has_token':bool(get_token(u)),
|
| 276 |
'has_rtmp':bool(t.get('rtmp'))} for u,t in tasks.items()]
|
| 277 |
tiktok_state=tiktok_auth.describe([t['username'] for t in task_list])
|
| 278 |
free_mb = round(disk.free/1048576,1)
|
| 279 |
+
return {'tasks':task_list,'logs':events[-60:],'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}
|
| 280 |
@app.post('/api/accounts')
|
| 281 |
def add(a:Account):
|
| 282 |
try:u=clean_user(a.username)
|
|
|
|
| 351 |
else:raise HTTPException(400)
|
| 352 |
save()
|
| 353 |
return {'ok':True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
@app.on_event('startup')
|
| 355 |
def startup():
|
| 356 |
load_tokens()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
try:saved=json.loads((ROOT/'tasks.json').read_text())
|
| 358 |
except FileNotFoundError:saved={}
|
| 359 |
except (OSError,ValueError):
|
|
|
|
| 382 |
deadline=time.monotonic()+50
|
| 383 |
for h in list(handles.values()):h['thread'].join(timeout=max(0,deadline-time.monotonic()))
|
| 384 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
index.html
CHANGED
|
@@ -43,7 +43,6 @@ textarea,select{font:inherit;background:#0c1220;color:#e4eaf7;border:1px solid #
|
|
| 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>
|
|
@@ -65,7 +64,6 @@ $('ytstart').disabled=!!y.automatic;$('ytstop').disabled=!y.automatic&&!evs.leng
|
|
| 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?'موجود ✓':'غير مضاف'} · مفتاح يوتيوب: ${t.has_rtmp?'موجود ✓ (بث فقط)':'غير مضاف'}</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}}
|
|
@@ -103,6 +101,6 @@ $('add').onsubmit=async e=>{e.preventDefault();try{error('');await api('/api/acc
|
|
| 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 |
-
|
| 107 |
document.addEventListener('visibilitychange',()=>{if(!document.hidden)refresh()});window.addEventListener('online',refresh);window.addEventListener('pageshow',refresh);refresh();setInterval(refresh,5000);
|
| 108 |
</script></body></html>
|
|
|
|
| 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><div id="logs"></div></section>
|
| 47 |
<footer>أسرار OAuth وتوكنات الجلسات تبقى في Secrets ومجلد البيانات فقط، ولا تظهر في الواجهة. راقب حصة التخزين من Hugging Face. تقدير API محلي وليس قراءة لحصة Google الفعلية. استخدمه للمحتوى الذي تملك حق إعادة نشره.</footer>
|
| 48 |
</main><script>
|
|
|
|
| 64 |
$('savesettings').disabled=!!y.automatic||!!evs.length;
|
| 65 |
if(!settingsLoaded&&y.settings)fillSettings(y.settings);
|
| 66 |
$('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?'موجود ✓':'غير مضاف'} · مفتاح يوتيوب: ${t.has_rtmp?'موجود ✓ (بث فقط)':'غير مضاف'}</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>';
|
|
|
|
| 67 |
$('logs').innerHTML=s.logs.slice().reverse().map(l=>`<div>${esc(l.time)} · @${esc(l.user)} · ${esc(l.message)}</div>`).join('');
|
| 68 |
renderTiktok(s.tiktok);
|
| 69 |
}catch(e){error(e.message)}finally{polling=false}}
|
|
|
|
| 101 |
document.addEventListener('click',async e=>{const b=e.target.closest('button');if(!b)return;try{
|
| 102 |
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}
|
| 103 |
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()}
|
| 104 |
+
}catch(e){error(e.message)}});
|
| 105 |
document.addEventListener('visibilitychange',()=>{if(!document.hidden)refresh()});window.addEventListener('online',refresh);window.addEventListener('pageshow',refresh);refresh();setInterval(refresh,5000);
|
| 106 |
</script></body></html>
|