288 lines
21 KiB
HTML
288 lines
21 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Shorts Autopilot</title>
|
|
<style>
|
|
:root{--bg:#0f1115;--panel:#181b22;--border:#2a2f3a;--text:#e6e8ee;--muted:#8a93a6;--accent:#e53935;--ok:#43a047}
|
|
*{box-sizing:border-box}
|
|
body{margin:0;font-family:system-ui,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text)}
|
|
header{display:flex;align-items:center;gap:16px;padding:14px 20px;border-bottom:1px solid var(--border)}
|
|
header h1{font-size:18px;margin:0;flex:1}
|
|
nav button{background:none;border:none;color:var(--muted);padding:8px 12px;cursor:pointer;font-size:14px}
|
|
nav button.active{color:var(--text);border-bottom:2px solid var(--accent)}
|
|
main{padding:20px;max-width:1100px;margin:0 auto}
|
|
.panel{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:16px;margin-bottom:16px;overflow-x:auto}
|
|
.row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
|
|
.btn{border:none;border-radius:8px;padding:10px 18px;font-size:15px;cursor:pointer;color:#fff;background:#3949ab;text-decoration:none;display:inline-block}
|
|
.btn:disabled{opacity:.4;cursor:default}
|
|
.btn.go{background:var(--ok);font-size:20px;padding:14px 40px;min-width:150px}
|
|
.btn.go.running{background:var(--accent)}
|
|
.btn.go.busy{background:#e65100}
|
|
.btn.stop{background:var(--accent)}
|
|
.btn.secondary{background:#394150}
|
|
.stat{min-width:150px}
|
|
.stat .k{color:var(--muted);font-size:12px}
|
|
.stat .v{font-size:17px;margin-top:4px}
|
|
.stat .v.small{font-size:13px;word-break:break-all}
|
|
.stat select{min-width:200px;margin-top:4px}
|
|
.badge{display:none;background:#6a1b9a;color:#fff;border-radius:10px;padding:3px 10px;font-size:12px}
|
|
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--border);vertical-align:top}
|
|
th{color:var(--muted);font-weight:500}
|
|
.tab{display:none}.tab.active{display:block}
|
|
#log{background:#0a0c10;border:1px solid var(--border);border-radius:8px;height:65vh;overflow:auto;padding:10px;font-family:Consolas,monospace;font-size:12px;white-space:pre-wrap}
|
|
.ERROR,.CRITICAL{color:#ef5350}.WARNING{color:#ffb74d}.DEBUG{color:#78909c}
|
|
label{display:block;font-size:12px;color:var(--muted);margin-bottom:4px}
|
|
input,select{width:100%;padding:8px;background:#0f1115;border:1px solid var(--border);border-radius:6px;color:var(--text)}
|
|
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:14px}
|
|
.grid h3{grid-column:1/-1;margin:10px 0 0;font-size:14px;color:var(--text);border-bottom:1px solid var(--border);padding-bottom:6px}
|
|
.grid .wide{grid-column:1/-1}
|
|
.hint{font-size:11px;color:var(--muted);margin-top:3px;word-break:break-all}
|
|
.hint.notes{color:#ffb74d;word-break:normal}
|
|
a{color:#64b5f6}
|
|
.pill{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:#394150}
|
|
.pill.published{background:#1b5e20}.pill.failed{background:#b71c1c}.pill.running{background:#e65100}.pill.review{background:#6a1b9a}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>Shorts Autopilot</h1>
|
|
<nav>
|
|
<button data-tab="dash" class="active">Dashboard</button>
|
|
<button data-tab="settings">Settings</button>
|
|
<button data-tab="logs">Log</button>
|
|
</nav>
|
|
</header>
|
|
<main>
|
|
<section id="dash" class="tab active">
|
|
<div class="panel row">
|
|
<button id="go" class="btn go">GO</button>
|
|
<div class="stat"><div class="k">Active profile</div><select id="prof-dash" class="prof-select"></select></div>
|
|
<div class="stat"><div class="k">Status</div><div class="v" id="st-stage">idle</div></div>
|
|
<div class="stat"><div class="k">Engine</div><div class="v" id="st-engine"></div></div>
|
|
<div class="stat"><div class="k">Published today</div><div class="v" id="st-today">0</div></div>
|
|
<div class="stat"><div class="k">Channel</div><div class="v" id="st-channel">Not linked</div></div>
|
|
<span class="badge" id="kids-badge">Made for kids: compliance checks on</span>
|
|
</div>
|
|
<div class="panel row">
|
|
<div class="stat"><div class="k">Storage folder</div><div class="v small" id="st-storage"></div></div>
|
|
<div class="stat"><div class="k">Free space</div><div class="v" id="st-free">?</div></div>
|
|
<div class="stat"><div class="k">After publishing</div><div class="v" id="st-after"></div></div>
|
|
</div>
|
|
<div class="panel row">
|
|
<button class="btn secondary" id="link">Link YouTube Channel</button>
|
|
<button class="btn secondary" id="preview">Preview Trending Videos</button>
|
|
<span class="hint" id="preview-msg"></span>
|
|
</div>
|
|
<div class="panel" id="trending-panel" style="display:none"><h3>Trending</h3><table id="trending"></table></div>
|
|
<div class="panel"><div class="row" style="justify-content:space-between"><h3 style="margin:0">Jobs</h3><button class="btn secondary" id="clear-jobs" style="padding:6px 12px;font-size:13px">Clear View</button></div><table id="jobs" style="margin-top:10px"></table></div>
|
|
</section>
|
|
<section id="settings" class="tab">
|
|
<div class="panel row">
|
|
<div class="stat"><div class="k">Editing profile</div><select id="prof-set" class="prof-select"></select></div>
|
|
<button class="btn secondary" id="new-profile">New profile</button>
|
|
<button class="btn stop" id="del-profile">Delete profile</button>
|
|
</div>
|
|
<div class="panel">
|
|
<div class="grid" id="settings-grid"></div>
|
|
<div class="row" style="margin-top:16px"><button class="btn" id="save">Save settings</button><span id="saved" class="hint"></span></div>
|
|
</div>
|
|
</section>
|
|
<section id="logs" class="tab">
|
|
<div class="panel row">
|
|
<label style="margin:0;display:flex;gap:6px;align-items:center"><input type="checkbox" id="autoscroll" checked style="width:auto">Auto scroll</label>
|
|
<button class="btn secondary" id="copy-log">Copy Log</button>
|
|
<button class="btn secondary" id="clear">Clear View</button>
|
|
<a href="/api/logs/download">Download full log</a>
|
|
<span class="hint" id="copy-msg"></span>
|
|
</div>
|
|
<div id="log"></div>
|
|
</section>
|
|
</main>
|
|
<script>
|
|
const CATS="1=Film and Animation|2=Autos and Vehicles|10=Music|15=Pets and Animals|17=Sports|19=Travel and Events|20=Gaming|22=People and Blogs|23=Comedy|24=Entertainment|25=News and Politics|26=Howto and Style|27=Education|28=Science and Technology";
|
|
const FIELDS=[
|
|
["","Profile: audience and channel","header","",""],
|
|
["name","Profile name","text","","p"],
|
|
["channel_niche","Channel niche","wide","Steers every script, e.g. 60 second animated movies for kids ages 3 to 7","p"],
|
|
["made_for_kids","Made for kids","checkbox","Flags uploads as made for kids and enforces child-safe rules, word filters, LLM compliance review, and frame vision checks","p"],
|
|
["kids_manual_review","Hold kids videos for manual review","checkbox","Forces made-for-kids uploads to private so you can watch them before publishing","p"],
|
|
["kids_llm_review","LLM script review","select:advisory=Advisory (show notes, you review)|block=Block (reject flagged scripts)|off=Off","Advisory only applies while manual review is on, otherwise it blocks. Word filters and vision checks always block","p"],
|
|
["upload_category_id","Upload category","select:"+CATS,"","p"],
|
|
["privacy_status","Upload privacy","select:private|unlisted|public","","p"],
|
|
["trending_query","Trending search query","text","Blank uses the general trending chart. e.g. kids animated story","p"],
|
|
["trending_days","Search window (days)","number","Only used with a search query","p"],
|
|
["category_id","Source category","select:=All|"+CATS,"Filters source videos","p"],
|
|
["region_code","Region","text","Two-letter country code, e.g. US","p"],
|
|
["source_language","Source language","text","Two-letter code, e.g. en. Skips source videos in other languages. Blank for any","p"],
|
|
["trending_count","Videos to scan","number","Max 50","p"],
|
|
["videos_per_day","Videos per day","number","0 runs continuously","p"],
|
|
["minutes_between_videos","Minutes between videos","number","0 for no gap","p"],
|
|
["","Folders (shared by all profiles)","header","",""],
|
|
["storage_dir","Storage folder","wide","Full path on a large disk, e.g. /Volumes/iDrive/shorts-autopilot. Jobs are built in output/ and moved to published/","g"],
|
|
["models_dir","Models folder","wide","Where AI models download to (several GB). Blank puts them in the storage folder","g"],
|
|
["temp_dir","Temp folder","wide","Scratch space used while rendering. Blank puts it in the storage folder","g"],
|
|
["delete_after_publish","Delete videos after publishing","checkbox","Removes the job folder once the upload succeeds instead of moving it to published","g"],
|
|
["keep_clips","Keep individual clips","checkbox","Keeps the scene clips, images, and narration alongside final.mp4","g"],
|
|
["auto_install","Install missing pieces automatically","checkbox","Downloads models and installs packages when a run needs them","g"],
|
|
["","Video engine (shared by all profiles)","header","",""],
|
|
["video_engine","Video engine","select:local=Local (free: AI images, narration, motion)|veo=Veo (paid Google API)","Local runs entirely on this machine at no cost. Veo makes true AI video but needs billing","g"],
|
|
["sd_model","Image model","text","Hugging Face model id, e.g. stabilityai/sdxl-turbo or stabilityai/stable-diffusion-xl-base-1.0","g"],
|
|
["sd_steps","Image steps","number","4 for turbo models, 20 to 30 for standard models","g"],
|
|
["sd_guidance","Image guidance","number","0 for turbo models, 7 for standard models","g"],
|
|
["sd_width","Image width","number","768 avoids duplicated subjects. Larger is slower","g"],
|
|
["sd_height","Image height","number","1344 is the correct vertical ratio for SDXL","g"],
|
|
["tts_engine","Narration engine","select:auto=Auto (best available)|kokoro=Kokoro (most natural)|piper=Piper|say=macOS say|espeak=espeak-ng","Missing engines are installed automatically on the next run","g"],
|
|
["tts_voice","Narration voice","text","Kokoro: af_heart, af_bella, am_michael, bf_emma. Ignored by the system voice","g"],
|
|
["tts_rate","Narration speed","number","Only used by the system voice. Words per minute","g"],
|
|
["piper_model","Piper voice file","wide","Blank uses the first voice found in the voices folder under Models","g"],
|
|
["gemini_api_key","Gemini API key (Veo)","password","Only needed for the Veo engine. Billing must be enabled","g"],
|
|
["veo_model","Veo model","text","e.g. veo-3.1-fast-generate-preview","g"],
|
|
["","Text and shared options","header","",""],
|
|
["ollama_url","Ollama URL","text","","g"],
|
|
["ollama_model","Ollama model","model:text","Writes scripts and tags. Models not installed yet are downloaded automatically","g"],
|
|
["vision_model","Ollama vision model","model:vision","Checks generated scenes on made-for-kids profiles. moondream suits 16 GB machines","g"],
|
|
["ollama_timeout","Ollama timeout (seconds)","number","Max wait per Ollama request before retrying","g"],
|
|
["aspect_ratio","Aspect ratio","select:9:16|16:9","","g"],
|
|
["target_seconds","Target length (seconds)","number","Split into scenes of the length below","g"],
|
|
["clip_seconds","Seconds per scene","number","8 gives 7 scenes in a 60 second Short","g"],
|
|
["error_cooldown_minutes","Wait after error (minutes)","number","","g"]
|
|
];
|
|
const $=s=>document.querySelector(s);
|
|
const esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]));
|
|
async function api(path,opts={}){const r=await fetch(path,{headers:{"Content-Type":"application/json"},...opts});const j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.detail||r.statusText);return j}
|
|
document.querySelectorAll("nav button").forEach(b=>b.onclick=()=>{document.querySelectorAll("nav button").forEach(x=>x.classList.remove("active"));document.querySelectorAll(".tab").forEach(x=>x.classList.remove("active"));b.classList.add("active");$("#"+b.dataset.tab).classList.add("active")});
|
|
function opt(o){const i=o.indexOf("=");return i<0?[o,o]:[o.slice(0,i),o.slice(i+1)]}
|
|
function renderProfiles(list,active){
|
|
document.querySelectorAll(".prof-select").forEach(sel=>{sel.innerHTML=list.map(p=>`<option value="${esc(p.id)}" ${p.id===active?"selected":""}>${esc(p.name)}${p.channel?" ("+esc(p.channel)+")":""}</option>`).join("")})}
|
|
async function loadSettings(){
|
|
const data=await api("/api/settings");
|
|
renderProfiles(data.profiles,data.active);
|
|
const g=$("#settings-grid");g.innerHTML="";
|
|
for(const [k,label,type,hint,scope] of FIELDS){
|
|
if(type==="header"){const h=document.createElement("h3");h.textContent=label;g.appendChild(h);continue}
|
|
const s=scope==="g"?data.global:data.profile;
|
|
let input;
|
|
if(type.startsWith("select:")){input=`<select id="f-${k}">${type.slice(7).split("|").map(o=>{const [v,l]=opt(o);return `<option value="${esc(v)}" ${String(s[k])===v?"selected":""}>${esc(l)}</option>`}).join("")}</select>`}
|
|
else if(type==="checkbox"){input=`<input type="checkbox" id="f-${k}" ${s[k]?"checked":""} style="width:auto">`}
|
|
else if(type.startsWith("model:")){
|
|
const kind=type.slice(6);
|
|
const installed=data.installed_models||[];
|
|
const suggested=(kind==="vision"?data.suggested_vision_models:data.suggested_text_models)||[];
|
|
const cur=String(s[k]||"");
|
|
const seen=new Set();
|
|
const opts=[];
|
|
for(const m of installed){if(seen.has(m))continue;seen.add(m);opts.push(`<option value="${esc(m)}" ${cur===m?"selected":""}>${esc(m)}</option>`)}
|
|
for(const m of suggested){if(seen.has(m)||seen.has(m+":latest"))continue;seen.add(m);opts.push(`<option value="${esc(m)}" ${cur===m?"selected":""}>${esc(m)} (not installed)</option>`)}
|
|
if(cur&&!seen.has(cur))opts.unshift(`<option value="${esc(cur)}" selected>${esc(cur)} (not installed)</option>`);
|
|
input=`<div class="row" style="gap:6px"><select id="f-${k}" style="flex:1">${opts.join("")}</select><button type="button" class="btn secondary pull" data-for="${k}" style="padding:8px 12px;font-size:13px">Pull now</button></div>`}
|
|
else{input=`<input type="${type==="wide"?"text":type}" id="f-${k}" value="${esc(s[k])}">`}
|
|
let extra="";
|
|
if(k==="storage_dir")extra=`<div class="hint">Resolves to: ${esc(data.storage_resolved)}</div>`;
|
|
if(k==="models_dir")extra=`<div class="hint">Resolves to: ${esc(data.models_resolved)}</div>`;
|
|
if(k==="temp_dir")extra=`<div class="hint">Resolves to: ${esc(data.temp_resolved)}</div>`;
|
|
const d=document.createElement("div");if(type==="wide")d.className="wide";
|
|
d.innerHTML=`<label>${label}</label>${input}${hint?`<div class="hint">${esc(hint)}</div>`:""}${extra}`;g.appendChild(d)}
|
|
document.querySelectorAll("button.pull").forEach(b=>b.onclick=async()=>{
|
|
const sel=$("#f-"+b.dataset.for);const model=sel.value;
|
|
b.disabled=true;const was=b.textContent;b.textContent="Pulling...";
|
|
try{await api("/api/models/pull",{method:"POST",body:JSON.stringify({model})});await loadSettings()}
|
|
catch(e){alert(e.message);b.disabled=false;b.textContent=was}});
|
|
syncFields()}
|
|
function syncFields(){
|
|
const kids=$("#f-made_for_kids");
|
|
if(kids){
|
|
for(const id of ["#f-kids_manual_review","#f-kids_llm_review"]){const el=$(id);if(!el)continue;el.disabled=!kids.checked;el.closest("div").style.opacity=kids.checked?1:.5}
|
|
kids.onchange=syncFields}
|
|
const eng=$("#f-video_engine");
|
|
if(eng){
|
|
const local=eng.value==="local";
|
|
for(const id of ["#f-sd_model","#f-sd_steps","#f-sd_guidance","#f-sd_width","#f-sd_height","#f-tts_engine","#f-tts_voice","#f-tts_rate","#f-piper_model"]){const el=$(id);if(!el)continue;el.disabled=!local;el.closest("div").style.opacity=local?1:.5}
|
|
for(const id of ["#f-gemini_api_key","#f-veo_model"]){const el=$(id);if(!el)continue;el.disabled=local;el.closest("div").style.opacity=local?.5:1}
|
|
eng.onchange=syncFields}}
|
|
async function saveSettings(){
|
|
const body={global:{},profile:{}};
|
|
for(const [k,,type,,scope] of FIELDS){if(type==="header")continue;const el=$("#f-"+k);body[scope==="g"?"global":"profile"][k]=type==="checkbox"?el.checked:type==="number"?Number(el.value):el.value}
|
|
try{await api("/api/settings",{method:"POST",body:JSON.stringify(body)});await loadSettings();$("#saved").textContent="Saved "+new Date().toLocaleTimeString();refresh()}catch(e){$("#saved").textContent="Error: "+e.message}}
|
|
async function activate(id){try{await api("/api/profiles/activate",{method:"POST",body:JSON.stringify({id})})}catch(e){alert(e.message)}await loadSettings();refresh()}
|
|
async function refresh(){
|
|
try{
|
|
const s=await api("/api/status");
|
|
$("#st-stage").textContent=s.stage;
|
|
$("#st-engine").textContent=s.engine==="local"?"Local (free)":"Veo (paid)";
|
|
$("#st-today").textContent=s.published_today+(s.videos_per_day>0?" / "+s.videos_per_day:"");
|
|
$("#st-channel").textContent=s.channel||"Not linked";
|
|
$("#st-storage").textContent=s.storage;
|
|
$("#st-free").textContent=s.storage_free_gb===null?"not created yet":s.storage_free_gb+" GB";
|
|
$("#st-free").style.color=s.storage_free_gb!==null&&s.storage_free_gb<10?"#ffb74d":"";
|
|
$("#st-after").textContent=s.delete_after_publish?"Delete files":"Move to published";
|
|
$("#kids-badge").style.display=s.made_for_kids?"inline-block":"none";
|
|
const lb=$("#link");lb.textContent=s.channel?"Unlink YouTube Channel":"Link YouTube Channel";lb.dataset.linked=s.channel?"1":"";
|
|
setButton(s.running);
|
|
$("#jobs").innerHTML="<tr><th>Started</th><th>Profile</th><th>Source</th><th>Title</th><th>Status</th><th>Video</th></tr>"+s.history.map(h=>`<tr><td>${esc(h.started)}</td><td>${esc(h.profile)}</td><td><a target="_blank" href="https://youtube.com/watch?v=${esc(h.source_id)}">${esc(h.source_title)}</a></td><td>${esc(h.title)}</td><td><span class="pill ${esc(h.status)}">${h.status==="review"?"private, needs review":esc(h.status)}</span>${h.notes?`<div class="hint notes">Reviewer notes: ${esc(h.notes)}</div>`:""}${h.error?`<div class="hint">${esc(h.error)}</div>`:""}${h.location?`<div class="hint">${esc(h.location)}</div>`:""}</td><td>${h.youtube_id?`<a target="_blank" href="https://studio.youtube.com/video/${esc(h.youtube_id)}/edit">studio</a> <a target="_blank" href="https://youtube.com/shorts/${esc(h.youtube_id)}">open</a>`:""}</td></tr>`).join("")
|
|
}catch(e){}}
|
|
let since=0;
|
|
async function pollLogs(){
|
|
try{
|
|
const r=await api("/api/logs?since="+since);const box=$("#log");
|
|
for(const [seq,level,line] of r.lines){const d=document.createElement("div");d.className=level;d.textContent=line;box.appendChild(d);since=seq}
|
|
while(box.childElementCount>3000)box.firstChild.remove();
|
|
if(r.lines.length&&$("#autoscroll").checked)box.scrollTop=box.scrollHeight
|
|
}catch(e){}}
|
|
document.querySelectorAll(".prof-select").forEach(sel=>sel.onchange=e=>activate(e.target.value));
|
|
$("#new-profile").onclick=async()=>{
|
|
const name=prompt("New profile name (starts as a copy of the current profile's settings)");
|
|
if(!name)return;
|
|
try{await api("/api/profiles",{method:"POST",body:JSON.stringify({name})});await loadSettings();refresh()}catch(e){alert(e.message)}};
|
|
$("#del-profile").onclick=async()=>{
|
|
const sel=$("#prof-set");const label=sel.options[sel.selectedIndex]?.text||"";
|
|
if(!confirm(`Delete profile "${label}" and its linked channel token? Videos on disk are kept.`))return;
|
|
try{await api("/api/profiles/delete",{method:"POST",body:JSON.stringify({id:sel.value})});await loadSettings();refresh()}catch(e){alert(e.message)}};
|
|
function setButton(running){
|
|
const b=$("#go");
|
|
if(b.dataset.busy==="1")return;
|
|
b.classList.toggle("running",running);
|
|
b.textContent=running?"STOP":"GO";
|
|
b.disabled=false}
|
|
$("#go").onclick=async()=>{
|
|
const b=$("#go");const running=b.classList.contains("running");
|
|
b.dataset.busy="1";b.disabled=true;b.classList.remove("running");b.classList.add("busy");
|
|
b.textContent=running?"Stopping...":"Starting...";
|
|
try{await api(running?"/api/stop":"/api/start",{method:"POST"})}catch(e){alert(e.message)}
|
|
b.dataset.busy="";b.classList.remove("busy");
|
|
setButton(!running);
|
|
refresh()};
|
|
$("#link").onclick=async()=>{
|
|
if($("#link").dataset.linked==="1"){
|
|
if(!confirm("Unlink the YouTube channel from this profile?"))return;
|
|
await api("/auth/unlink",{method:"POST"});await loadSettings();refresh()}
|
|
else{window.location.href="/auth/start"}};
|
|
$("#save").onclick=saveSettings;
|
|
$("#clear").onclick=()=>{$("#log").innerHTML=""};
|
|
$("#copy-log").onclick=async()=>{
|
|
const text=$("#log").innerText.trim();
|
|
if(!text){$("#copy-msg").textContent="Nothing to copy";return}
|
|
try{await navigator.clipboard.writeText(text);$("#copy-msg").textContent="Copied "+text.split("\n").length+" lines"}
|
|
catch(e){
|
|
const ta=document.createElement("textarea");ta.value=text;document.body.appendChild(ta);ta.select();
|
|
try{document.execCommand("copy");$("#copy-msg").textContent="Copied"}catch(err){$("#copy-msg").textContent="Could not copy"}
|
|
ta.remove()}
|
|
setTimeout(()=>{$("#copy-msg").textContent=""},3000)};
|
|
$("#clear-jobs").onclick=async()=>{
|
|
if(!confirm("Clear the job list? Videos on disk and on YouTube are not touched."))return;
|
|
try{await api("/api/history/clear",{method:"POST"});refresh()}catch(e){alert(e.message)}};
|
|
$("#preview").onclick=async()=>{
|
|
$("#preview-msg").textContent="Loading...";
|
|
try{
|
|
const r=await api("/api/trending");
|
|
$("#trending").innerHTML="<tr><th>Views</th><th>Title</th><th>Channel</th></tr>"+r.items.map(v=>`<tr><td>${v.views.toLocaleString()}</td><td><a target="_blank" href="https://youtube.com/watch?v=${esc(v.id)}">${esc(v.title)}</a></td><td>${esc(v.channel)}</td></tr>`).join("");
|
|
$("#trending-panel").style.display="block";$("#preview-msg").textContent=""
|
|
}catch(e){$("#preview-msg").textContent="Error: "+e.message}};
|
|
loadSettings();refresh();pollLogs();
|
|
setInterval(refresh,3000);setInterval(pollLogs,2000);
|
|
</script>
|
|
</body>
|
|
</html>
|