'use strict';
const API='';
let state={currentView:'jobs',selectedJobId:null,inputType:'url',selectedArtifacts:new Set(['title','stats','transcript']),outputFolder:'',browserPath:'',jobs:[],sseSource:null,pollTimer:null,settings:{},apiKey:'',currentVideoId:null};
const AM={title:{icon:'📝',label:'Title'},stats:{icon:'📊',label:'Stats'},transcript:{icon:'📄',label:'Transcript'},summary:{icon:'🤖',label:'Summary'},thumbnail:{icon:'🖼',label:'Thumbnail'},mp3:{icon:'🎵',label:'MP3'},mp4:{icon:'🎬',label:'MP4'}};
// ── API ──────────────────────────────────────────────────────────
async function apiFetch(path,opts={}){
const res=await fetch(API+path,{headers:{'Content-Type':'application/json'},...opts});
if(!res.ok){const e=await res.json().catch(()=>({detail:res.statusText}));throw new Error(e.detail||res.statusText);}
return res.json();
}
// ── View Management ───────────────────────────────────────────────
function showView(name){
['jobs','new','settings'].forEach(v=>{const el=document.getElementById('view-'+v);if(el)el.classList.toggle('hidden',v!==name);});
document.querySelectorAll('.nav-btn').forEach(b=>b.classList.remove('active'));
const nb=document.getElementById('nav-'+name);if(nb)nb.classList.add('active');
state.currentView=name;
if(name==='settings')renderSettings();
if(name==='jobs'&&state.selectedJobId)loadJobDetail(state.selectedJobId);
}
// ── Artifact Grid ─────────────────────────────────────────────────
function renderArtifactGrid(){
const grid=document.getElementById('artifact-grid');if(!grid)return;
grid.innerHTML=Object.entries(AM).map(([k,m])=>
''
).join('');
}
function toggleArtifact(key,el){
if(state.selectedArtifacts.has(key)){state.selectedArtifacts.delete(key);el.classList.remove('checked');}
else{state.selectedArtifacts.add(key);el.classList.add('checked');}
document.getElementById('resolution-group').classList.toggle('hidden',!state.selectedArtifacts.has('mp4'));
}
function selectInputType(type){
state.inputType=type;
document.querySelectorAll('.type-pill').forEach(p=>p.classList.toggle('selected',p.dataset.type===type));
const isCsv=type==='csv';
document.getElementById('input-url-group').classList.toggle('hidden',isCsv);
document.getElementById('input-csv-group').classList.toggle('hidden',!isCsv);
const labels={url:'YouTube URL',playlist:'Playlist URL',handle:'Channel Handle (@username)'};
const phs={url:'https://www.youtube.com/watch?v=...',playlist:'https://www.youtube.com/playlist?list=...',handle:'@ChannelName'};
const lbl=document.getElementById('input-label');const inp=document.getElementById('input-value');
if(lbl)lbl.textContent=labels[type]||type;if(inp)inp.placeholder=phs[type]||'';
}
// ── Create Job ────────────────────────────────────────────────────
async function createJob(){
const inputVal=(state.inputType==='csv'
?document.getElementById('input-csv-value').value
:document.getElementById('input-value').value).trim();
if(!inputVal){toast('Please enter a YouTube URL/handle/playlist.','error');return;}
if(!state.outputFolder){toast('Please choose an output folder.','error');return;}
if(state.selectedArtifacts.size===0){toast('Select at least one artifact to extract.','error');return;}
const btn=document.getElementById('create-btn');
btn.disabled=true;btn.innerHTML=' Starting…';
try{
const body={
input_type:state.inputType,
input_value:inputVal,
output_folder:state.outputFolder,
selections:Array.from(state.selectedArtifacts),
resolution:document.getElementById('resolution').value,
delay_min:parseFloat(document.getElementById('delay-min').value)||2,
delay_max:parseFloat(document.getElementById('delay-max').value)||7,
openrouter_api_key:document.getElementById('api-key').value.trim(),
openrouter_model:document.getElementById('or-model').value.trim()||'openai/gpt-4o-mini',
summary_prompt:document.getElementById('summary-prompt').value.trim(),
};
state.apiKey=body.openrouter_api_key;
const res=await apiFetch('/api/jobs',{method:'POST',body:JSON.stringify(body)});
toast('Job started! Extracting '+body.selections.join(', '),'success');
state.selectedJobId=res.job_id;
showView('jobs');
await loadJobDetail(res.job_id);
subscribeSSE(res.job_id);
refreshSidebar();
}catch(e){toast('Error: '+e.message,'error');}
finally{btn.disabled=false;btn.innerHTML='▲ Start Extraction';}
}
// ── Job Controls ──────────────────────────────────────────────────
async function pauseJob(id){try{await apiFetch('/api/jobs/'+id+'/pause',{method:'POST'});toast('Job paused','info');refreshJobDetail(id);}catch(e){toast(e.message,'error');}}
async function resumeJob(id){
const key=state.apiKey||prompt('Enter OpenRouter API key (needed for summaries, or leave blank):','');
try{await apiFetch('/api/jobs/'+id+'/resume',{method:'POST',body:JSON.stringify({openrouter_api_key:key||''})});
toast('Job resumed','info');subscribeSSE(id);refreshJobDetail(id);}catch(e){toast(e.message,'error');}
}
async function stopJob(id){if(!confirm('Stop this job?'))return;try{await apiFetch('/api/jobs/'+id+'/stop',{method:'POST'});toast('Job stopped','info');refreshJobDetail(id);}catch(e){toast(e.message,'error');}}
async function skipVideo(id){try{await apiFetch('/api/jobs/'+id+'/skip',{method:'POST'});toast('Skip signal sent','info');}catch(e){toast(e.message,'error');}}
// ── Folder Browser ────────────────────────────────────────────────
function openFolderBrowser(){
document.getElementById('folder-modal').classList.remove('hidden');
browseTo(state.browserPath||'');
}
function closeFolderModal(){document.getElementById('folder-modal').classList.add('hidden');}
async function browseTo(path){
try{
const data=await apiFetch('/api/browse?path='+encodeURIComponent(path));
state.browserPath=data.current;
document.getElementById('folder-current-path').textContent=data.current;
document.getElementById('folder-modal-path').value=data.current;
const list=document.getElementById('folder-browser-list');
let html='';
if(data.parent) html+='
⬆.. (parent)
';
(data.entries||[]).forEach(e=>{
if(e.is_dir) html+='📁'+escH(e.name)+'
';
});
list.innerHTML=html||'(empty folder)
';
}catch(err){toast('Browse error: '+err.message,'error');}
}
function navigateToTyped(){browseTo(document.getElementById('folder-modal-path').value.trim());}
async function createNewFolder(){
const name=prompt('New folder name:','youtube-downloads');if(!name)return;
const newPath=state.browserPath.replace(/\\/g,'/')+'/'+name;
try{await apiFetch('/api/mkdir',{method:'POST',body:JSON.stringify({path:newPath})});browseTo(newPath);}
catch(e){toast('Could not create folder: '+e.message,'error');}
}
function selectCurrentFolder(){
state.outputFolder=state.browserPath;
document.getElementById('output-folder').value=state.browserPath;
document.getElementById('folder-path-text').textContent=state.browserPath;
closeFolderModal();
toast('Folder selected: '+state.browserPath,'success');
}
function escJ(s){return s.replace(/\\/g,'\\\\').replace(/'/g,"\\'");}
function escH(s){return s.replace(/&/g,'&').replace(//g,'>');}
// ── SSE Subscription ──────────────────────────────────────────────
function subscribeSSE(jobId){
if(state.sseSource){state.sseSource.close();state.sseSource=null;}
const es=new EventSource(API+'/api/jobs/'+jobId+'/events');
state.sseSource=es;
const handle=(type,fn)=>es.addEventListener(type,e=>{try{fn(JSON.parse(e.data));}catch(ex){}});
handle('job_status',d=>{refreshJobDetail(d.job_id);refreshSidebar();});
handle('video_list',d=>{renderVideoTable(jobId,d.videos||[]);});
handle('video_start',d=>{state.currentVideoId=d.video_id;highlightVideoRow(d.video_id);});
handle('video_title',d=>{updateVideoTitle(d.video_id,d.title);});
handle('video_done',d=>{updateVideoRow(jobId,d.video_id);updateProgressBar(d.done,d.total);refreshSidebar();});
handle('artifact_start',d=>{updateArtifactPill(d.video_id,d.artifact_type,'running');appendLog(d.job_id,'INFO','['+d.artifact_type+'] Starting attempt '+(d.attempt||1));});
handle('artifact_done',d=>{updateArtifactPill(d.video_id,d.artifact_type,'done');appendLog(d.job_id,'INFO','['+d.artifact_type+'] Done: '+d.file_path);});
handle('artifact_failed',d=>{updateArtifactPill(d.video_id,d.artifact_type,'failed');appendLog(d.job_id,'ERROR','['+d.artifact_type+'] FAILED: '+d.error);});
handle('artifact_skip',d=>{updateArtifactPill(d.video_id,d.artifact_type,'done');});
handle('progress',d=>{appendLog(jobId,'INFO',d.message);});
handle('stream_end',()=>{es.close();state.sseSource=null;refreshJobDetail(jobId);});
es.onerror=()=>{if(es.readyState===EventSource.CLOSED){state.sseSource=null;}};
}
// ── Job Detail ────────────────────────────────────────────────────
async function loadJobDetail(jobId){
state.selectedJobId=jobId;
try{
const [job,videos]=await Promise.all([apiFetch('/api/jobs/'+jobId),apiFetch('/api/jobs/'+jobId+'/videos')]);
renderJobDetail(job,videos);
if(job.status==='running'&&!state.sseSource) subscribeSSE(jobId);
if(['running','paused'].includes(job.status)&&!state.pollTimer){
state.pollTimer=setInterval(()=>refreshJobDetail(jobId),8000);
}
}catch(e){document.getElementById('job-detail').innerHTML='Error loading job
'+escH(e.message)+'
';}
}
async function refreshJobDetail(jobId){
if(state.currentView!=='jobs'||state.selectedJobId!==jobId)return;
try{
const [job,videos]=await Promise.all([apiFetch('/api/jobs/'+jobId),apiFetch('/api/jobs/'+jobId+'/videos')]);
updateJobHeader(job);
updateVideoTableRows(jobId,videos);
if(['done','done_with_errors','stopped','failed'].includes(job.status)){
clearInterval(state.pollTimer);state.pollTimer=null;
}
}catch(e){}
}
function renderJobDetail(job,videos){
const jd=document.getElementById('job-detail');
const stats=job;
const pct=stats.total_videos>0?Math.round(stats.done_videos/stats.total_videos*100):0;
const isActive=['running','paused'].includes(job.status);
jd.innerHTML=
''+
''+
''+
''+
'
'+
''+
'
'+
'| # | Video ID | Title | Status | Artifacts |
'+
''+renderVideoRows(videos)+''+
'
'+
'
'+
''+
'
'+
'
'+
'
';
}
function switchTab(name,btn){
document.querySelectorAll('.view-tab').forEach(b=>b.classList.remove('active'));btn.classList.add('active');
document.getElementById('tab-videos').classList.toggle('hidden',name!=='videos');
document.getElementById('tab-logs').classList.toggle('hidden',name!=='logs');
if(name==='logs') loadLogs(state.selectedJobId);
}
function updateJobHeader(job){
const badge=document.getElementById('jd-badge');if(badge){badge.className='badge badge-'+job.status;badge.textContent=job.status.replace('_',' ');}
const done=document.getElementById('jd-done');if(done)done.textContent=job.done_videos||0;
const ad=document.getElementById('jd-art-done');if(ad)ad.textContent=job.artifact_done||0;
const af=document.getElementById('jd-art-fail');if(af)af.textContent=job.artifact_failed||0;
const ap=document.getElementById('jd-art-pend');if(ap)ap.textContent=job.artifact_pending||0;
const total=job.total_videos||1;const pct=Math.round((job.done_videos||0)/total*100);
const pb=document.getElementById('jd-progress');if(pb)pb.style.width=pct+'%';
const pp=document.getElementById('jd-pct');if(pp)pp.textContent=pct+'% complete';
}
function updateProgressBar(done,total){
const pct=total>0?Math.round(done/total*100):0;
const pb=document.getElementById('jd-progress');if(pb)pb.style.width=pct+'%';
const pp=document.getElementById('jd-pct');if(pp)pp.textContent=pct+'% complete';
const dd=document.getElementById('jd-done');if(dd)dd.textContent=done;
}
// ── Video Table Helpers ───────────────────────────────────────────
function renderVideoRows(videos){
if(!videos||!videos.length) return '| No videos loaded yet… |
';
return videos.map((v,i)=>{
const arts=(v.artifacts||[]);
const rowStatus=v.status||'pending';
return ''+
'| '+((v.position||i)+1)+' | '+
''+v.video_id+' | '+
''+(v.title?escH(v.title):'Loading…')+' | '+
''+rowStatus+' | '+
''+renderArtPills(arts)+' | '+
'
';
}).join('');
}
function renderArtPills(arts){
return arts.map(a=>{
const cls='art-pill art-pill-'+a.status;
const m=AM[a.artifact_type]||{icon:'?'};
const title=a.error_msg?'Error: '+a.error_msg:(a.file_path?'File: '+a.file_path:'');
return ''+m.icon+(a.artifact_type?'':a.artifact_type)+'';
}).join('');
}
function renderVideoTable(jobId,videos){
const tbody=document.getElementById('video-tbody');
if(tbody) tbody.innerHTML=renderVideoRows(videos);
}
function updateVideoTableRows(jobId,videos){
videos.forEach(v=>{
const row=document.getElementById('vrow-'+v.video_id);
if(!row) return;
const tc=row.querySelector('.badge');if(tc){tc.className='badge badge-'+v.status;tc.textContent=v.status;}
const pills=document.getElementById('vpills-'+v.video_id);
if(pills) pills.innerHTML=renderArtPills(v.artifacts||[]);
const titleEl=document.getElementById('vtitle-'+v.video_id);
if(titleEl&&v.title) titleEl.textContent=v.title;
});
}
function updateVideoRow(jobId,videoId){
apiFetch('/api/jobs/'+jobId+'/videos').then(videos=>{
const v=videos.find(x=>x.video_id===videoId);if(!v)return;
const row=document.getElementById('vrow-'+videoId);if(!row)return;
const tc=row.querySelector('.badge');if(tc){tc.className='badge badge-'+v.status;tc.textContent=v.status;}
const pills=document.getElementById('vpills-'+videoId);
if(pills)pills.innerHTML=renderArtPills(v.artifacts||[]);
}).catch(()=>{});
}
function highlightVideoRow(videoId){
document.querySelectorAll('.video-table tr').forEach(r=>r.classList.remove('active-row'));
const r=document.getElementById('vrow-'+videoId);if(r){r.classList.add('active-row');r.scrollIntoView({block:'nearest'});}
}
function updateArtifactPill(videoId,artType,status){
const pills=document.getElementById('vpills-'+videoId);if(!pills)return;
const existing=pills.querySelector('[data-art="'+artType+'"]');
if(existing){existing.className='art-pill art-pill-'+status;existing.querySelector('.art-dot').className='art-dot art-dot-'+status;}
}
function updateVideoTitle(videoId,title){
const el=document.getElementById('vtitle-'+videoId);if(el)el.textContent=title;
}
// ── Live Log Panel ────────────────────────────────────────────────
async function loadLogs(jobId){
if(!jobId)return;
try{
const logs=await apiFetch('/api/jobs/'+jobId+'/logs?limit=200');
const panel=document.getElementById('log-panel');if(!panel)return;
panel.innerHTML=logs.map(l=>{
const ts=l.timestamp?l.timestamp.replace('T',' ').substring(0,19):'';
return ''+
''+ts+''+
''+l.level+''+
''+escH(l.message)+''+
'
';
}).join('');
panel.scrollTop=panel.scrollHeight;
}catch(e){}
}
function appendLog(jobId,level,msg){
const panel=document.getElementById('log-panel');if(!panel)return;
const ts=new Date().toISOString().replace('T',' ').substring(0,19);
const div=document.createElement('div');div.className='log-entry';
div.innerHTML=''+ts+''+level+''+escH(msg)+'';
panel.appendChild(div);
if(panel.children.length>400) panel.removeChild(panel.children[0]);
panel.scrollTop=panel.scrollHeight;
}
// ── Sidebar ──────────────────────────────────────────────────────
async function refreshSidebar(){
try{
const jobs=await apiFetch('/api/jobs');
state.jobs=jobs;
const list=document.getElementById('sidebar-job-list');
if(!list)return;
if(!jobs||!jobs.length){list.innerHTML='No jobs yet
';return;}
list.innerHTML=jobs.slice(0,15).map(j=>{
const icons={running:'🔵',done:'✅',paused:'⏸',stopped:'⏹',failed:'❌',done_with_errors:'⚠',pending:'🟣'};
const icon=icons[j.status]||'●';
const pct=j.total_videos>0?Math.round((j.done_videos||0)/j.total_videos*100):0;
return ''+
'
'+icon+' '+escH(j.input_type.toUpperCase())+' - '+j.job_id.substring(0,12)+'…
'+
'
'+j.done_videos+'/'+j.total_videos+' videos'+pct+'%
'+
'
';
}).join('');
}catch(e){}
}
function selectJob(jobId){
if(state.sseSource){state.sseSource.close();state.sseSource=null;}
clearInterval(state.pollTimer);state.pollTimer=null;
showView('jobs');
loadJobDetail(jobId);
refreshSidebar();
}
// ── Settings ──────────────────────────────────────────────────────
function renderSettings(){
apiFetch('/api/config').then(cfg=>{
const s=state.settings;
const dm=document.getElementById('s-delay-min');if(dm)dm.value=s.delay_min||cfg.delay_min||2;
const dx=document.getElementById('s-delay-max');if(dx)dx.value=s.delay_max||cfg.delay_max||7;
const sm=document.getElementById('s-model');if(sm)sm.value=s.model||cfg.default_model||'openai/gpt-4o-mini';
const sp=document.getElementById('s-prompt');if(sp)sp.value=s.prompt||cfg.summary_prompt||'';
const snippet=document.getElementById('mcp-config-snippet');
if(snippet){
const mcpCfg={mcpServers:{"youtube-sipoc":{command:"python",args:[window.location.pathname.replace('/','')+'mcp_server.py']}}};
snippet.textContent=JSON.stringify(mcpCfg,null,2);
}
}).catch(()=>{});
}
function saveSettings(){
state.settings={
delay_min:parseFloat(document.getElementById('s-delay-min').value)||2,
delay_max:parseFloat(document.getElementById('s-delay-max').value)||7,
model:document.getElementById('s-model').value.trim(),
prompt:document.getElementById('s-prompt').value.trim(),
};
localStorage.setItem('sipoc_settings',JSON.stringify(state.settings));
// Apply to new job form
const dm=document.getElementById('delay-min');if(dm)dm.value=state.settings.delay_min;
const dx=document.getElementById('delay-max');if(dx)dx.value=state.settings.delay_max;
const mo=document.getElementById('or-model');if(mo)mo.value=state.settings.model;
const sp=document.getElementById('summary-prompt');if(sp)sp.value=state.settings.prompt;
toast('Settings saved','success');
}
function copyMcpConfig(){
const el=document.getElementById('mcp-config-snippet');if(!el)return;
navigator.clipboard.writeText(el.textContent).then(()=>toast('MCP config copied!','success')).catch(()=>toast('Copy failed','error'));
}
function loadSettings(){
const saved=localStorage.getItem('sipoc_settings');
if(saved){try{state.settings=JSON.parse(saved);}catch(e){}}
}
// ── Toast Notifications ───────────────────────────────────────────
function toast(msg,type='info'){
const c=document.getElementById('toast-container');if(!c)return;
const d=document.createElement('div');d.className='toast '+type;
const icons={success:'✅',error:'❌',info:'ℹ'};
d.innerHTML=''+(icons[type]||'ℹ')+''+escH(msg)+'';
c.appendChild(d);
setTimeout(()=>{d.style.opacity='0';d.style.transition='opacity 0.4s';setTimeout(()=>d.remove(),400);},4000);
}
// ── Init ──────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded',()=>{
loadSettings();
renderArtifactGrid();
apiFetch('/api/config').then(cfg=>{
if(!state.settings.model){const mo=document.getElementById('or-model');if(mo)mo.value=cfg.default_model||'openai/gpt-4o-mini';}
if(!state.settings.delay_min){const dm=document.getElementById('delay-min');if(dm)dm.value=cfg.delay_min||2;}
if(!state.settings.delay_max){const dx=document.getElementById('delay-max');if(dx)dx.value=cfg.delay_max||7;}
if(!state.settings.prompt){const sp=document.getElementById('summary-prompt');if(sp)sp.value=cfg.summary_prompt||'';}
}).catch(()=>{});
refreshSidebar();
// Poll sidebar every 10s for job status updates
setInterval(refreshSidebar,10000);
// Handle Enter on folder input
const fi=document.getElementById('folder-modal-path');
if(fi)fi.addEventListener('keydown',e=>{if(e.key==='Enter')navigateToTyped();});
});