'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= '
'+ '
'+ '
'+ '
'+ '
'+ 'Job: '+ ''+job.job_id+''+ ''+job.status.replace('_',' ')+''+ '
'+ '
📁 '+escH(job.output_folder)+'
'+ '
Input: '+job.input_type+' | Artifacts: '+(job.selections||[]).join(', ')+'
'+ '
'+ '
'+ '
'+ (job.status==='running'? ''+ ''+ '' :job.status==='paused'? ''+ '' :(job.status==='stopped'||job.status==='failed'||job.status==='done_with_errors')? '' :'')+ '⬇ Download Log CSV'+ '
'+ '
'+ '
'+stats.done_videos+'
Videos Done
'+ '
'+stats.total_videos+'
Total Videos
'+ '
'+stats.artifact_done+'
Artifacts OK
'+ '
'+stats.artifact_failed+'
Artifacts Failed
'+ '
'+stats.artifact_pending+'
Pending
'+ '
'+ '
'+ '
'+pct+'% complete
'+ '
'+ '
'+ '
'+ ''+ ''+ '
'+ '
'+ '
'+ ''+ ''+renderVideoRows(videos)+''+ '
#Video IDTitleStatusArtifacts
'+ '
'+ ''; } 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();}); });