Reooiq commited on
Commit
d0be95c
·
1 Parent(s): f189d53

purge all local-recording remnants: files endpoints/UI, smart_cleanup/janitor, recordings dir, mkvmerge dep — broadcast-only

Browse files
Files changed (3) hide show
  1. Dockerfile +1 -1
  2. app.py +4 -101
  3. 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 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 .
 
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, 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)):
@@ -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(FILES)
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:],'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}
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
- 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>
 
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>