438 lines
50 KiB
JavaScript
438 lines
50 KiB
JavaScript
'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])=>
|
||
'<label class="artifact-check '+(state.selectedArtifacts.has(k)?'checked':'')+'" onclick="toggleArtifact(\''+k+'\',this)">'+
|
||
'<span class="artifact-icon">'+m.icon+'</span><span class="artifact-label">'+m.label+'</span></label>'
|
||
).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='<span class="spinner"></span> 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+='<div class="folder-entry" onclick="browseTo(\''+escJ(data.parent)+'\')"><span class="entry-icon">⬆</span>.. (parent)</div>';
|
||
(data.entries||[]).forEach(e=>{
|
||
if(e.is_dir) html+='<div class="folder-entry" onclick="browseTo(\''+escJ(e.path)+'\')"><span class="entry-icon">📁</span>'+escH(e.name)+'</div>';
|
||
});
|
||
list.innerHTML=html||'<div style="padding:12px;color:var(--text-muted)">(empty folder)</div>';
|
||
}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,'<').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='<div class="empty-state"><div class="empty-state-title text-red">Error loading job</div><div>'+escH(e.message)+'</div></div>';}
|
||
}
|
||
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=
|
||
'<div id="jd-header">'+
|
||
'<div class="card">'+
|
||
'<div class="card-header">'+
|
||
'<div class="flex-1">'+
|
||
'<div class="flex-row" style="margin-bottom:6px">'+
|
||
'<span class="card-title">Job: </span>'+
|
||
'<span class="monospace text-dim" style="font-size:12px">'+job.job_id+'</span>'+
|
||
'<span class="badge badge-'+job.status+'" id="jd-badge">'+job.status.replace('_',' ')+'</span>'+
|
||
'</div>'+
|
||
'<div class="text-dim" style="font-size:12px">📁 '+escH(job.output_folder)+'</div>'+
|
||
'<div class="text-muted" style="font-size:11px">Input: '+job.input_type+' | Artifacts: '+(job.selections||[]).join(', ')+'</div>'+
|
||
'</div>'+
|
||
'</div>'+
|
||
'<div class="job-controls">'+
|
||
(job.status==='running'?
|
||
'<button class="btn btn-outline btn-sm" onclick="pauseJob(\''+job.job_id+'\')">⏸ Pause</button>'+
|
||
'<button class="btn btn-outline btn-sm" onclick="skipVideo(\''+job.job_id+'\')">⏭ Skip Video</button>'+
|
||
'<button class="btn btn-danger btn-sm" onclick="stopJob(\''+job.job_id+'\')">⏹ Stop</button>'
|
||
:job.status==='paused'?
|
||
'<button class="btn btn-green btn-sm" onclick="resumeJob(\''+job.job_id+'\')">▶ Resume</button>'+
|
||
'<button class="btn btn-danger btn-sm" onclick="stopJob(\''+job.job_id+'\')">⏹ Stop</button>'
|
||
:(job.status==='stopped'||job.status==='failed'||job.status==='done_with_errors')?
|
||
'<button class="btn btn-blue btn-sm" onclick="resumeJob(\''+job.job_id+'\')">↩ Resume from Checkpoint</button>'
|
||
:'')+
|
||
'<a class="btn btn-outline btn-sm" href="/api/jobs/'+job.job_id+'/logs/csv" download>⬇ Download Log CSV</a>'+
|
||
'</div>'+
|
||
'<div class="stats-row" style="margin:12px 0">'+
|
||
'<div class="stat-item"><div class="stat-value" id="jd-done">'+stats.done_videos+'</div><div class="stat-label">Videos Done</div></div>'+
|
||
'<div class="stat-item"><div class="stat-value">'+stats.total_videos+'</div><div class="stat-label">Total Videos</div></div>'+
|
||
'<div class="stat-item"><div class="stat-value text-green" id="jd-art-done">'+stats.artifact_done+'</div><div class="stat-label">Artifacts OK</div></div>'+
|
||
'<div class="stat-item"><div class="stat-value text-red" id="jd-art-fail">'+stats.artifact_failed+'</div><div class="stat-label">Artifacts Failed</div></div>'+
|
||
'<div class="stat-item"><div class="stat-value text-dim" id="jd-art-pend">'+stats.artifact_pending+'</div><div class="stat-label">Pending</div></div>'+
|
||
'</div>'+
|
||
'<div class="progress-bar"><div class="progress-fill '+(pct===100?'green':pct>0?'blue':'')+'" id="jd-progress" style="width:'+pct+'%"></div></div>'+
|
||
'<div style="font-size:11px;color:var(--text-muted);text-align:right" id="jd-pct">'+pct+'% complete</div>'+
|
||
'</div>'+
|
||
'</div>'+
|
||
'<div class="view-tabs">'+
|
||
'<button class="view-tab active" onclick="switchTab(\'videos\',this)">Videos ('+videos.length+')</button>'+
|
||
'<button class="view-tab" onclick="switchTab(\'logs\',this)">Live Log</button>'+
|
||
'</div>'+
|
||
'<div id="tab-videos">'+
|
||
'<div class="video-table-wrap"><table class="video-table" id="video-table">'+
|
||
'<thead><tr><th>#</th><th>Video ID</th><th>Title</th><th>Status</th><th>Artifacts</th></tr></thead>'+
|
||
'<tbody id="video-tbody">'+renderVideoRows(videos)+'</tbody>'+
|
||
'</table></div>'+
|
||
'</div>'+
|
||
'<div id="tab-logs" class="hidden">'+
|
||
'<div class="log-panel" id="log-panel"></div>'+
|
||
'<div style="margin-top:8px"><button class="btn btn-outline btn-sm" onclick="loadLogs(\''+job.job_id+'\')">🔄 Refresh Logs</button></div>'+
|
||
'</div>';
|
||
}
|
||
|
||
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 '<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:20px">No videos loaded yet…</td></tr>';
|
||
return videos.map((v,i)=>{
|
||
const arts=(v.artifacts||[]);
|
||
const rowStatus=v.status||'pending';
|
||
return '<tr id="vrow-'+v.video_id+'" class="'+(state.currentVideoId===v.video_id?'active-row':'')+'">'+
|
||
'<td class="text-muted">'+((v.position||i)+1)+'</td>'+
|
||
'<td><span class="video-id-badge"><a href="https://youtube.com/watch?v='+v.video_id+'" target="_blank">'+v.video_id+'</a></span></td>'+
|
||
'<td class="video-title-cell" id="vtitle-'+v.video_id+'">'+(v.title?escH(v.title):'<span class="text-muted">Loading…</span>')+'</td>'+
|
||
'<td><span class="badge badge-'+rowStatus+'">'+rowStatus+'</span></td>'+
|
||
'<td><div class="artifact-pills" id="vpills-'+v.video_id+'">'+renderArtPills(arts)+'</div></td>'+
|
||
'</tr>';
|
||
}).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 '<span class="'+cls+'" title="'+escH(title)+'"><span class="art-dot art-dot-'+a.status+'"></span>'+m.icon+(a.artifact_type?'':a.artifact_type)+'</span>';
|
||
}).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 '<div class="log-entry">'+
|
||
'<span class="log-ts">'+ts+'</span>'+
|
||
'<span class="log-level log-level-'+l.level+'">'+l.level+'</span>'+
|
||
'<span class="log-msg">'+escH(l.message)+'</span>'+
|
||
'</div>';
|
||
}).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='<span class="log-ts">'+ts+'</span><span class="log-level log-level-'+level+'">'+level+'</span><span class="log-msg">'+escH(msg)+'</span>';
|
||
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='<div class="text-muted" style="font-size:12px;padding:8px 4px">No jobs yet</div>';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 '<div class="job-item'+(j.job_id===state.selectedJobId?' active':'')+'" onclick="selectJob(\''+j.job_id+'\')">'+
|
||
'<div class="job-item-title">'+icon+' '+escH(j.input_type.toUpperCase())+' - '+j.job_id.substring(0,12)+'…</div>'+
|
||
'<div class="job-item-meta"><span>'+j.done_videos+'/'+j.total_videos+' videos</span><span>'+pct+'%</span></div>'+
|
||
'</div>';
|
||
}).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='<span>'+(icons[type]||'ℹ')+'</span><span class="toast-msg">'+escH(msg)+'</span>';
|
||
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();});
|
||
});
|