On / index.html
Reoo88's picture
Revert assistant HD changes; restore original relay behavior
23e9e2b verified
Raw History Blame Contribute Delete
22.6 kB
<!doctype html><html lang="ar" dir="rtl"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>TikTok Live Monitor</title>
<style>
:root{color-scheme:dark;font-family:system-ui,sans-serif}
body{margin:0;background:#0b1220;color:#e6edf7}
main{max-width:1080px;margin:32px auto;padding:0 18px}
h1{font-size:28px;margin:0}h2{font-size:18px;margin:0 0 12px}
p,small{color:#9db0c9;line-height:1.7}
.panel{background:#141c2c;border:1px solid #2a3850;border-radius:16px;padding:20px;margin:18px 0}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px}
.card{border:1px solid #31425c;border-radius:12px;padding:16px}
.card h3{margin:0 0 6px;direction:ltr;text-align:right}
.row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
input,button,select,textarea{font:inherit;border-radius:10px;padding:10px 12px;border:1px solid #3a4d68}
input,select,textarea{background:#0b1220;color:#fff;flex:1;min-width:120px}
button{background:#6fe0b4;color:#10251d;font-weight:650;cursor:pointer}
button.secondary{background:#223249;color:#d5e2f5}
button.danger{background:#3e2430;color:#ffb9c8}
.badge{display:inline-block;border:1px solid #355445;border-radius:999px;padding:3px 10px;color:#78e1b7;font-size:12px}
.badge.off{border-color:#5a3a3a;color:#f0b4b4}
.badge.live{border-color:#3d8;color:#7f6}
.kv{display:grid;grid-template-columns:120px 1fr;gap:6px 10px;font-size:14px}
.kv b{color:#c5d4e8;font-weight:600}
#logs{max-height:260px;overflow:auto;font:13px/1.8 ui-monospace,monospace;background:#0b1220;border-radius:10px;padding:10px}
#error{color:#ffc4c4;white-space:pre-line}
label{display:block;margin:10px 0 6px;color:#c5d4e8}
textarea{width:100%;box-sizing:border-box}
details{margin-top:12px}
footer{color:#8a9bb3;font-size:12px;margin:24px 0}
a{color:#79dcc5}
</style></head><body><main>
<header class="row" style="justify-content:space-between">
<div><small>TikTok Live Monitor → Direct Relay → YouTube</small><h1>رصد البث المباشر</h1></div>
<div class="row"><span class="badge" id="sysbadge">System</span><button class="secondary" id="logout" type="button" hidden>خروج</button></div>
</header>
<p id="error" hidden></p>
<section class="panel" id="login-panel">
<h2>دخول الإدارة</h2>
<p>استخدم كلمة السر المحفوظة في Secret باسم <code>PASSWORD</code>. لا تستخدم توكن Hugging Face هنا.</p>
<form id="login" class="row"><input id="admin-password" type="password" autocomplete="current-password" placeholder="كلمة السر" required><button>دخول</button></form>
<p id="login-error" hidden></p>
</section>
<section class="panel" id="accounts-panel" hidden>
<h2>Accounts</h2>
<form id="add" class="row"><input id="username" placeholder="اسم المستخدم أو رابط الحساب" required>
<input id="token" placeholder="توكن sessionid — اختياري للغرف 18+">
<button>إضافة ومراقبة</button></form>
<p class="row"><button class="secondary" id="reload" type="button">تحديث</button><small id="updated"></small></p>
<div id="tasks" class="cards"></div>
<details><summary>توكن تيك توك (للبثوث المقيدة عمريًا)</summary>
<label>الصق كامل سطر Cookie أو JSON المُصدَّر من إضافة الكوكيز</label>
<textarea id="tokinput" rows="2" dir="ltr" placeholder="sessionid=...; sid_tt=..."></textarea>
<div class="row" style="margin-top:8px">
<button id="tokvalidate" class="secondary" type="button">فحص التوكن</button>
<button id="toksave" type="button">حفظ لكل الحسابات</button>
<input id="tokuser" dir="ltr" placeholder="اختبار حساب">
<button id="tokcheck" class="secondary" type="button">اختبر الفتح</button>
</div>
<p id="tokresult" hidden></p>
<div id="tokstate"></div>
</details>
</section>
<section class="panel" id="youtube-panel" hidden>
<h2>YouTube</h2>
<div class="row">
<button id="ytcheck" class="secondary" type="button">فحص OAuth والقناة</button>
<button id="ytadopt" class="secondary" type="button" hidden>اعتماد القناة الجديدة فقط</button>
<button id="ytstart" type="button">تفعيل البث التلقائي</button>
<button id="ytstop" class="danger" type="button">إيقاف وإنهاء البث الجاري</button>
</div>
<p id="ytchannelnotice" role="status" aria-live="polite" hidden></p>
<div class="kv" style="margin:14px 0">
<b>الاتصال</b><span id="ytconn">—</span>
<b>حالة البث</b><span id="ytstate">—</span>
<b>العنوان الحالي</b><span id="yttitle">—</span>
<b>الجلسة</b><span id="ytsession">—</span>
<b>Relay</b><span id="ytrelay">—</span>
</div>
<div id="events" class="cards"></div>
<div class="row">
<select id="sendmode">
<option value="auto">auto — نسخ H264 إن ثبت، وإلا تحويل خفيف 720</option>
<option value="copy">copy — بدون إعادة ترميز</option>
<option value="fixed720">fixed720 — كانفس ثابت (CPU أعلى)</option>
<option value="fixed1080">fixed1080 — كانفس 1080</option>
</select>
<button class="secondary" id="sendmodeset" type="button">تطبيق الوضع</button>
</div>
<details><summary>قوالب العنوان والوصف</summary>
<form id="settingsform">
<label>الخصوصية</label>
<select id="privacy"><option value="public">عام</option><option value="unlisted">غير مُدرج</option><option value="private">خاص</option></select>
<label>قالب العنوان — الافتراضي: {broadcaster} | {date} {time}</label>
<input id="titletemplate">
<label>قالب الوصف</label>
<textarea id="desctemplate" rows="4"></textarea>
<p><small>المتغيرات: {broadcaster} {name} {title} {username} {date} {time} {hashtags} {room_id} — التاريخ والوقت بتوقيت بغداد لحظة بدء جلسة تيك توك.</small></p>
<button type="submit" id="savesettings">حفظ</button>
</form>
</details>
</section>
<section class="panel" id="system-panel" hidden>
<h2>System</h2>
<div class="kv">
<b>Monitoring</b><span id="mon">—</span>
<b>الحسابات</b><span id="nacc">—</span>
<b>Relay active</b><span id="nrel">—</span>
<b>CPU</b><span id="ncpu">—</span>
<b>RAM</b><span id="nram">—</span>
<b>آخر خطأ</b><span id="lasterr">لا يوجد</span>
</div>
<h2 style="margin-top:16px">السجل الحي</h2>
<div id="logs"></div>
</section>
<footer>لا تسجيل محلي ولا أرشيف فيديو. المسار: TikTok → الذاكرة/الأنبوب → FFmpeg → YouTube RTMPS. أسرار OAuth والتوكنات لا تظهر هنا.</footer>
</main>
<script>
const $=id=>document.getElementById(id),esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// An OAuth check is the operator's candidate, not the old journal's active channel.
// Periodic refresh must NEVER overwrite it with y.channel from the previous channel.
let settingsLoaded=false,checkedChannel=null,lastY=null,polling=false,pollingSince=0,youtubeBusy=false;
let authHeader='';try{authHeader=sessionStorage.getItem('on-basic-auth')||''}catch{}
function error(text){$('error').textContent=text;$('error').hidden=!text}
function showAuth(ok){
$('login-panel').hidden=ok;$('accounts-panel').hidden=!ok;$('youtube-panel').hidden=!ok;$('system-panel').hidden=!ok;$('logout').hidden=!ok;
if(!ok){authHeader='';checkedChannel=null;lastY=null;settingsLoaded=false;renderCheckedChannel();try{sessionStorage.removeItem('on-basic-auth')}catch{};$('admin-password').focus()}
}
function basicHeader(password){
const bytes=new TextEncoder().encode('admin:'+password);let raw='';for(const b of bytes)raw+=String.fromCharCode(b);return 'Basic '+btoa(raw)
}
async function api(url,method='GET',body){
const headers={};if(body)headers['Content-Type']='application/json';if(authHeader)headers.Authorization=authHeader;
const r=await fetch(url,{method,cache:'no-store',headers,body:body?JSON.stringify(body):undefined});
let j;try{j=await r.json()}catch{}
if(r.status===401){showAuth(false);throw Error('كلمة السر غير صحيحة أو انتهت جلسة الصفحة')}
if(!r.ok)throw Error(typeof j?.detail==='string'?j.detail:'تعذر تنفيذ الطلب');return j;
}
function fillSettings(s){$('privacy').value=s.privacy||'public';$('titletemplate').value=s.title||'';$('desctemplate').value=s.description||'';settingsLoaded=true}
function channelSwitchPending(){return !!(checkedChannel?.previous&&checkedChannel.previous.id!==checkedChannel.id)}
function renderCheckedChannel(){
const pending=channelSwitchPending(),notice=$('ytchannelnotice');
$('ytadopt').hidden=!pending;notice.hidden=!checkedChannel;
notice.textContent=!checkedChannel?'':pending
? 'القناة الجديدة بعد الفحص: «'+checkedChannel.title+'» ('+checkedChannel.id+') بدل «'+checkedChannel.previous.title+'». لم تُعتمد بعد؛ يمكنك اعتمادها فقط دون تشغيل بث. أحداث القناة السابقة تُؤرشف محليًا ولا تُعدَّل على يوتيوب.'
: 'القناة المؤكدة بالفحص: «'+checkedChannel.title+'» ('+checkedChannel.id+').';
}
function updateYouTubeControls(){
const y=lastY||{};
$('ytcheck').disabled=youtubeBusy;$('ytadopt').disabled=youtubeBusy;
$('ytstart').disabled=youtubeBusy||!!y.automatic;
$('ytstop').disabled=youtubeBusy||(!y.automatic&&!(y.events||[]).length);
}
async function checkYouTubeChannel(){
const j=await api('/api/youtube/check','POST');
if(!j.channel?.id)throw Error('لم يُرجع الفحص قناة مفوضة');
checkedChannel=j.channel;renderCheckedChannel();return checkedChannel;
}
function channelConfirmation(channel,start){
const previous=channel.previous&&channel.previous.id!==channel.id?channel.previous:null;
let text=previous?'تبديل القناة من «'+previous.title+'» إلى «'+channel.title+'» ('+channel.id+')؟\nستتوقف مرسلات القناة السابقة وتُؤرشف أحداثها محليًا فقط؛ لن تُحذف أو تُعدَّل على يوتيوب.\n'
:'القناة: «'+channel.title+'» ('+channel.id+').\n';
return text+(start?'تفعيل Relay التلقائي وإنشاء بث عند رصد LIVE على هذه القناة؟':'اعتماد القناة فقط، مع إبقاء الأتمتة معطلة عند التبديل؟');
}
function stateLabel(st){
const m={OFFLINE:'Offline',CHECKING:'فحص',LIVE_DETECTED:'اكتشاف LIVE',STARTING:'بدء',LIVE:'LIVE',STALLED:'توقّف المصدر',RECONNECTING:'إعادة اتصال',STOPPING:'إيقاف',ERROR:'خطأ',BACKOFF:'انتظار'};
return m[st]||st||'—';
}
function fmtDur(s){s=Math.max(0,s|0);const h=Math.floor(s/3600),m=Math.floor((s%3600)/60);return h?h+'س '+m+'د':m+'د '+ (s%60)+'ث'}
async function refresh(){
if(!authHeader||document.hidden)return;
if(polling&&Date.now()-(pollingSince||0)<30000)return;
polling=true;pollingSince=Date.now();
try{
const [ry,rs]=await Promise.all([
api('/api/youtube').then(v=>({v})).catch(e=>({e})),
api('/api/state').then(v=>({v})).catch(e=>({e}))
]);
const y=ry.v||{events:[],settings:null,channel:null};
const s0=rs.v||{tasks:[],logs:[]};
const ytError=ry.e?String(ry.e.message||ry.e):'', stateError=rs.e?String(rs.e.message||rs.e):'';
if(ry.v)lastY=y;
const evs=y.events||[], tasks=s0.tasks||[];
const notes=[]; if(ytError)notes.push(ytError); if(stateError)notes.push(stateError); error(notes.join('\n'));
$('tasks').innerHTML=tasks.length?tasks.map(x=>{
const live=x.state==='LIVE';
return `<article class="card"><h3>@${esc(x.username)}</h3>
<p><span class="badge ${live?'live':(x.state==='OFFLINE'?'off':'')}">${esc(stateLabel(x.state))}</span>
${x.nickname?' · '+esc(x.nickname):''}</p>
<p>LIVE منذ: ${live?esc(fmtDur(x.live_for||0)):'—'}</p>
<p>المصدر: ${esc(x.source||'—')}</p>
<p>Codec: ${esc(x.codec||'—')} · ${esc(x.quality||'')} · room ${esc(x.room_id||'—')}</p>
<p>PID: ${x.relay_pid||'—'} · بايت: ${esc(x.bytes_received||0)} · إعادة: ${esc(x.restarts||0)}</p>
<p>آخر بايت: ${x.source_age==null||x.source_age===''?'—':(Number(x.source_age).toFixed(1)+'ث')}</p>
<p>YouTube: ${esc(x.youtube_status||'—')} · health ${esc(x.youtube_health||'—')}${x.relay_alive?' · Relay':''}</p>
<small>${esc(x.status||'')}${x.last_error?' · '+esc(x.last_error):''}</small>
<p>توكن: ${x.has_token?'موجود ✓':'غير مضاف'}</p>
<div class="row">
<button data-user="${esc(x.username)}" data-action="start">تشغيل</button>
<button class="secondary" data-user="${esc(x.username)}" data-action="stop">إيقاف</button>
<button class="secondary" data-user="${esc(x.username)}" data-action="token">توكن</button>
<button class="danger" data-user="${esc(x.username)}" data-action="delete">حذف</button>
</div></article>`;
}).join(''):'<p>أضف حسابات للمراقبة.</p>';
const ev=evs[0]||null;
const conn=[];
if(y.oauth_configured||y.configured) conn.push(y.channel?('OAuth: '+y.channel.title):'OAuth موجود');
else conn.push('OAuth غير مفعّل');
if(y.rtmp_configured||s0.rtmp_configured) conn.push('RTMPS env جاهز');
else conn.push('لا YOUTUBE_RTMP_URL/KEY');
if(!(y.metadata_automation||s0.metadata_automation)) conn.push('تحديث العنوان غير متاح بدون OAuth — الفيديو لا يتوقف');
$('ytconn').textContent=conn.join(' · ');
$('ytstate').textContent=y.automatic?(y.status||'أتمتة OAuth مفعّلة'):(s0.rtmp_configured?'Relay عبر RTMPS بدون OAuth':'معطّل');
const liveTask=tasks.find(t=>t.relay_alive||t.state==='LIVE');
$('yttitle').textContent=ev?(ev.title||'—'):(liveTask&&liveTask.session_title)||'لا بث جارٍ';
$('ytsession').textContent=ev?(ev.username+' · '+(ev.youtube_status||ev.phase||'')):(liveTask?liveTask.username:'—');
$('ytrelay').textContent=String(s0.active_relays||evs.length||0)+' نشط';
$('events').innerHTML=evs.length?evs.map(e=>`<article class="card"><h3>${esc(e.title||'بث')}</h3>
<p>@${esc(e.username)} · ${esc(e.phase||'')} · YT ${esc(e.youtube_status||'—')}</p>
<p>المصدر: ${esc(e.source||'—')}</p>
<p>وضع الإرسال الفعلي: ${esc(e.send_mode||'قيد التحديد')}</p>
${e.pending_send_mode?`<p>الترميز الثابت ينتظر خانة: ${esc(e.pending_send_mode)}</p>`:''}
<small>${esc(e.status||e.note||'')} · ${Math.floor(e.output_seconds||0)} ث</small>
${e.broadcast_id?`<p><a target="_blank" rel="noopener" href="https://www.youtube.com/watch?v=${encodeURIComponent(e.broadcast_id)}">فتح على يوتيوب</a></p>`:''}
</article>`).join(''):'<p>عند LIVE يُفتح Relay عبر RTMPS (مفتاح البيئة أو OAuth). فشل تحديث العنوان لا يوقف الفيديو.</p>';
const lastErr=tasks.map(t=>t.last_error).filter(Boolean)[0] || (s0.logs||[]).filter(l=>/error|فشل|stall|خطأ/i.test(l.message||'')).slice(-1)[0]?.message || 'لا يوجد';
$('mon').textContent=s0.monitor_running?'يعمل':'متوقف';
$('nacc').textContent=String(tasks.length);
$('nrel').textContent=String(s0.active_relays||evs.length||0)+' / '+(Number(s0.max_concurrent_relays)===0?'∞':(s0.max_concurrent_relays||'?'));
$('ncpu').textContent=(s0.cpu_pct_of_quota!=null
? (s0.cpu_pct_of_quota+'% من '+(s0.quota_cpus||'?')+' vCPU')
: (s0.load1!=null?('load '+s0.load1):'—'));
$('nram').textContent=(s0.mem_avail_mb!=null?(s0.mem_avail_mb+' MB متاح / '+s0.mem_total_mb+' MB'):'—');
$('lasterr').textContent=lastErr;
$('sysbadge').textContent=s0.monitor_running?'Monitor · ON':'Monitor · OFF';
$('updated').textContent='آخر تحديث '+new Date().toLocaleTimeString('ar-IQ')+' · فحص كل '+(s0.check_interval||'?')+'ث · stall '+(s0.source_stall_timeout||'?')+'ث';
const cur=y.send_mode_setting||y.send_mode||'auto';
const sel=$('sendmode'); if(document.activeElement!==sel) sel.value=['auto','fixed720','fixed1080','copy'].includes(cur)?cur:'auto';
updateYouTubeControls();renderCheckedChannel();
$('savesettings').disabled=!!y.automatic||!!evs.length;
if(!settingsLoaded&&y.settings)fillSettings(y.settings);
$('logs').innerHTML=(s0.logs||[]).slice().reverse().map(l=>`<div>${esc(l.time||'')} · @${esc(l.user||'')} · ${esc(l.message||'')}</div>`).join('')||'<p>لا أحداث بعد.</p>';
renderTiktok(s0.tiktok);
} finally {polling=false}
}
function renderTiktok(t){if(!t){$('tokstate').innerHTML='';return}
const accts=Object.entries(t.accounts||{});
let rows=`<small>توكن عام: ${t.default_authenticated?esc(t.default_masked):'غير مضبوط'}</small>`;
rows+=accts.map(([u,v])=>`<div>@${esc(u)} — ${v.authenticated?esc(v.masked):'يعتمد على العام'}</div>`).join('');
$('tokstate').innerHTML=rows}
function tokShow(html){const e=$('tokresult');e.innerHTML=html;e.hidden=false}
$('login').onsubmit=async e=>{e.preventDefault();const p=$('admin-password').value;const msg=$('login-error');msg.hidden=true;
authHeader=basicHeader(p);try{await api('/api/state');try{sessionStorage.setItem('on-basic-auth',authHeader)}catch{};$('admin-password').value='';showAuth(true);await refresh()}
catch(err){showAuth(false);msg.textContent='تعذر الدخول: '+String(err.message||err);msg.hidden=false}};
$('logout').onclick=()=>{showAuth(false);error('');$('login-error').hidden=true};
$('tokvalidate').onclick=async()=>{const raw=$('tokinput').value.trim();if(!raw){tokShow('الصق التوكن أولًا');return}
try{$('tokvalidate').disabled=true;const r=await api('/api/tiktok/token/validate','POST',{token:raw});tokShow(esc(r.message||''));if(r.ok)await refresh()}catch(e){tokShow(esc(e.message))}finally{$('tokvalidate').disabled=false}};
$('toksave').onclick=async()=>{const raw=$('tokinput').value.trim();if(!raw){tokShow('الصق التوكن أولًا');return}
try{$('toksave').disabled=true;const r=await api('/api/tiktok/token/validate','POST',{token:raw});tokShow(esc(r.message||''));if(r.ok){$('tokinput').value='';await refresh()}}catch(e){tokShow(esc(e.message))}finally{$('toksave').disabled=false}};
$('tokcheck').onclick=async()=>{const u=$('tokuser').value.trim();if(!u){tokShow('اكتب اسم الحساب');return}
try{$('tokcheck').disabled=true;const r=await api('/api/tiktok/token/check/'+encodeURIComponent(u),'POST');tokShow(esc(r.message||r.result||''))}catch(e){tokShow(esc(e.message))}finally{$('tokcheck').disabled=false}};
$('sendmodeset').onclick=async()=>{try{$('sendmodeset').disabled=true;await api('/api/youtube/settings','POST',{send_mode:$('sendmode').value});await refresh()}catch(e){error(e.message)}finally{$('sendmodeset').disabled=false}};
$('ytcheck').onclick=async()=>{
if(youtubeBusy)return;youtubeBusy=true;updateYouTubeControls();
try{const channel=await checkYouTubeChannel();await refresh();alert('القناة المفوضة: '+channel.title+(channelSwitchPending()?' — بانتظار تأكيد اعتمادها':''))}
catch(e){error(e.message)}finally{youtubeBusy=false;updateYouTubeControls()}
};
$('ytadopt').onclick=async()=>{
if(youtubeBusy)return;youtubeBusy=true;updateYouTubeControls();
try{
const channel=await checkYouTubeChannel();
if(!confirm(channelConfirmation(channel,false)))return;
const j=await api('/api/youtube/channel','POST',{channel_id:channel.id});
checkedChannel=j.channel;renderCheckedChannel();await refresh();
alert('تم اعتماد القناة «'+j.channel.title+'». اعتماد القناة وحده لا يبدأ أي بث.');
}catch(e){error(e.message)}finally{youtubeBusy=false;updateYouTubeControls()}
};
$('ytstart').onclick=async()=>{
if(youtubeBusy)return;youtubeBusy=true;updateYouTubeControls();
try{
// Recheck even after a previous check: OAuth Secrets may have changed in another tab.
const channel=await checkYouTubeChannel();
if(!confirm(channelConfirmation(channel,true)))return;
await api('/api/youtube/start','POST',{channel_id:channel.id});
checkedChannel={id:channel.id,title:channel.title};renderCheckedChannel();await refresh();
}catch(e){error(e.message)}finally{youtubeBusy=false;updateYouTubeControls()}
};
$('ytstop').onclick=async()=>{
if(youtubeBusy||!confirm('إيقاف الأتمتة وإنهاء البث الجاري؟'))return;
youtubeBusy=true;updateYouTubeControls();
try{await api('/api/youtube/stop','POST');await refresh()}catch(e){error(e.message)}finally{youtubeBusy=false;updateYouTubeControls()}
};
$('settingsform').onsubmit=async e=>{e.preventDefault();try{await api('/api/youtube/settings','POST',{name:'',privacy:$('privacy').value,title:$('titletemplate').value,description:$('desctemplate').value});settingsLoaded=false;await refresh()}catch(e){error(e.message)}};
$('add').onsubmit=async e=>{e.preventDefault();try{await api('/api/accounts','POST',{username:$('username').value,token:$('token').value});$('username').value='';$('token').value='';await refresh()}catch(e){error(e.message)}};
document.addEventListener('click',async e=>{const b=e.target.closest('button');if(!b)return;try{
if(b.dataset.action==='token'){const t=prompt('توكن جلسة تيك توك للحساب @'+b.dataset.user+' (فارغ للحذف):');if(t===null)return;await api('/api/accounts/'+encodeURIComponent(b.dataset.user)+'/token','POST',{token:t});await refresh();return}
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()}
}catch(e){error(e.message)}});
$('reload').onclick=()=>refresh();
document.addEventListener('visibilitychange',()=>{if(!document.hidden)refresh()});
showAuth(!!authHeader);if(authHeader)refresh();setInterval(refresh,10000);
</script></body></html>