Spaces:
Sleeping
Sleeping
rsnarsna
feat: Enhance transcript extraction with multi-tier fallback; add YouTube API support and update UI to display extraction method
0bcc65e | <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>YT Summariser</title> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css" /> | |
| <style> | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { font-family: system-ui, sans-serif; background: #f9f9f8; color: #1a1a18; min-height: 100vh; display: flex; align-items: center; justify-content: center; } | |
| .wrap { width: 100%; max-width: 460px; padding: 2.5rem 1.5rem; } | |
| /* ── Logo ── */ | |
| .logo { display: flex; align-items: center; gap: 10px; margin-bottom: 2.5rem; } | |
| .logo-icon { width: 36px; height: 36px; background: #E24B4A; border-radius: 8px; display: flex; align-items: center; justify-content: center; } | |
| .logo-icon i { color: #fff; font-size: 18px; } | |
| .logo-text { font-size: 15px; font-weight: 600; } | |
| .logo-sub { font-size: 12px; color: #888; } | |
| /* ── Auth banner ── */ | |
| .auth-banner { | |
| display: flex; align-items: center; justify-content: space-between; | |
| padding: 10px 14px; border-radius: 8px; margin-bottom: 1.5rem; | |
| font-size: 13px; border: 1px solid #ddd; background: #fff; | |
| } | |
| .auth-banner.ok { border-color: #86efac; background: #f0fdf4; } | |
| .auth-banner.bad { border-color: #fca5a5; background: #fff5f5; } | |
| .auth-left { display: flex; align-items: center; gap: 8px; } | |
| .auth-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } | |
| .auth-dot.ok { background: #22c55e; } | |
| .auth-dot.bad { background: #ef4444; } | |
| .auth-text { color: #444; } | |
| .auth-btn { | |
| font-size: 12px; font-weight: 600; padding: 5px 12px; | |
| border-radius: 6px; border: none; cursor: pointer; | |
| background: #1a1a18; color: #fff; text-decoration: none; | |
| display: inline-flex; align-items: center; gap: 5px; | |
| transition: opacity .15s; | |
| } | |
| .auth-btn:hover { opacity: .8; } | |
| .auth-btn.connected { background: #dcfce7; color: #166534; cursor: default; } | |
| /* ── Heading ── */ | |
| h1 { font-size: 22px; font-weight: 600; margin-bottom: 6px; } | |
| .subtitle { font-size: 14px; color: #666; margin-bottom: 2rem; line-height: 1.6; } | |
| /* ── Form ── */ | |
| .field { margin-bottom: 1rem; } | |
| label { display: block; font-size: 13px; color: #555; margin-bottom: 5px; } | |
| input[type=text], input[type=email] { | |
| width: 100%; padding: 9px 12px; font-size: 14px; | |
| border: 1px solid #ddd; border-radius: 8px; background: #fff; | |
| outline: none; transition: border-color .15s; | |
| } | |
| input:focus { border-color: #E24B4A; } | |
| /* ── Buttons ── */ | |
| .btn { | |
| width: 100%; padding: 10px; font-size: 14px; font-weight: 600; | |
| cursor: pointer; border-radius: 8px; border: none; | |
| background: #E24B4A; color: #fff; margin-top: .5rem; | |
| display: flex; align-items: center; justify-content: center; gap: 8px; | |
| transition: opacity .15s; | |
| } | |
| .btn:hover { opacity: .88; } | |
| .btn:disabled { opacity: .5; cursor: not-allowed; } | |
| /* ── Error ── */ | |
| .err { | |
| font-size: 13px; color: #a32d2d; margin-top: 1rem; display: none; | |
| padding: 10px 12px; border: 1px solid #f09595; border-radius: 8px; | |
| background: #fcebeb; | |
| } | |
| /* ── Status box ── */ | |
| .status-box { | |
| margin-top: 1.5rem; border: 1px solid #e5e5e3; | |
| border-radius: 12px; overflow: hidden; display: none; background: #fff; | |
| } | |
| .status-header { | |
| padding: 12px 16px; display: flex; align-items: center; | |
| justify-content: space-between; border-bottom: 1px solid #e5e5e3; | |
| } | |
| .status-label { font-size: 13px; font-weight: 600; } | |
| .badge { font-size: 11px; padding: 3px 8px; border-radius: 20px; font-weight: 600; } | |
| .badge-running { background: #dbeafe; color: #1e40af; } | |
| .badge-done { background: #dcfce7; color: #166534; } | |
| .badge-failed { background: #fee2e2; color: #991b1b; } | |
| /* ── Steps ── */ | |
| .steps { padding: 12px 16px; display: flex; flex-direction: column; gap: 9px; } | |
| .step { display: flex; align-items: center; gap: 10px; font-size: 13px; color: #999; } | |
| .step.active { color: #1a1a18; } | |
| .step-icon { | |
| width: 20px; height: 20px; border-radius: 50%; | |
| display: flex; align-items: center; justify-content: center; | |
| flex-shrink: 0; font-size: 11px; border: 1px solid #ddd; background: #f5f5f4; | |
| } | |
| .step-icon.done { background: #dcfce7; border-color: #86efac; color: #166534; } | |
| .step-icon.running { background: #dbeafe; border-color: #93c5fd; color: #1e40af; } | |
| .step-icon.failed { background: #fee2e2; border-color: #fca5a5; color: #991b1b; } | |
| /* ── Result links ── */ | |
| .result-box { | |
| padding: 12px 16px; border-top: 1px solid #e5e5e3; | |
| display: none; flex-direction: column; gap: 8px; | |
| } | |
| .result-link { display: flex; align-items: center; gap: 8px; font-size: 13px; } | |
| .result-link a { color: #1e40af; text-decoration: none; } | |
| .result-link a:hover { text-decoration: underline; } | |
| .result-note { font-size: 12px; color: #888; margin-top: 2px; } | |
| /* ── Spinner ── */ | |
| .spinner { | |
| width: 11px; height: 11px; border: 1.5px solid currentColor; | |
| border-top-color: transparent; border-radius: 50%; | |
| animation: spin .7s linear infinite; display: inline-block; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="wrap"> | |
| <!-- Logo --> | |
| <div class="logo"> | |
| <div class="logo-icon"><i class="ti ti-brand-youtube"></i></div> | |
| <div> | |
| <div class="logo-text">YT Summariser</div> | |
| <div class="logo-sub">Transcript → Summary → Q&A</div> | |
| </div> | |
| </div> | |
| <!-- Auth banner --> | |
| <div class="auth-banner bad" id="auth-banner"> | |
| <div class="auth-left"> | |
| <div class="auth-dot bad" id="auth-dot"></div> | |
| <span class="auth-text" id="auth-text">Google not connected</span> | |
| </div> | |
| <a href="/auth/start" class="auth-btn" id="auth-btn" target="_blank" onclick="onAuthClick()"> | |
| <i class="ti ti-brand-google"></i> Connect Google | |
| </a> | |
| </div> | |
| <!-- Heading --> | |
| <h1>Summarise a YouTube video</h1> | |
| <p class="subtitle">Paste a YouTube link and your email — we'll process the transcript and send results to your inbox.</p> | |
| <!-- Form --> | |
| <div class="field"> | |
| <label for="yt-url">YouTube URL</label> | |
| <input type="text" id="yt-url" placeholder="https://www.youtube.com/watch?v=..." /> | |
| </div> | |
| <div class="field"> | |
| <label for="email">Email address</label> | |
| <input type="email" id="email" placeholder="you@example.com" /> | |
| </div> | |
| <button class="btn" id="submit-btn" onclick="submitJob()"> | |
| <i class="ti ti-player-play"></i> Start processing | |
| </button> | |
| <div class="err" id="err-box"></div> | |
| <!-- Status box --> | |
| <div class="status-box" id="status-box"> | |
| <div class="status-header"> | |
| <span class="status-label" id="status-label">Processing…</span> | |
| <span class="badge badge-running" id="status-badge">Running</span> | |
| </div> | |
| <div class="steps" id="steps-list"></div> | |
| <div class="result-box" id="result-box"></div> | |
| </div> | |
| </div> | |
| <script> | |
| /* ── Step config ── */ | |
| const STEP_LABELS = { | |
| fetch_transcript: 'Fetching transcript', | |
| summarize: 'Summarising with Gemini', | |
| create_drive_folder: 'Creating Drive folder', | |
| upload_summary: 'Uploading summary', | |
| upload_qa: 'Uploading Q&A', | |
| upload_transcript: 'Uploading transcript', | |
| send_email: 'Sending email', | |
| log_sheet: 'Logging to Sheets', | |
| }; | |
| const STEP_ICONS = { | |
| fetch_transcript: 'ti-file-text', | |
| summarize: 'ti-brain', | |
| create_drive_folder: 'ti-folder-plus', | |
| upload_summary: 'ti-upload', | |
| upload_qa: 'ti-upload', | |
| upload_transcript: 'ti-upload', | |
| send_email: 'ti-mail', | |
| log_sheet: 'ti-table', | |
| }; | |
| let pollTimer = null; | |
| /* ── Auth status check ── */ | |
| async function checkAuth() { | |
| try { | |
| const res = await fetch('/auth/status'); | |
| const data = await res.json(); | |
| setAuthBanner(data.authenticated); | |
| } catch (e) { | |
| setAuthBanner(false); | |
| } | |
| } | |
| function setAuthBanner(ok) { | |
| const banner = document.getElementById('auth-banner'); | |
| const dot = document.getElementById('auth-dot'); | |
| const text = document.getElementById('auth-text'); | |
| const btn = document.getElementById('auth-btn'); | |
| if (ok) { | |
| banner.className = 'auth-banner ok'; | |
| dot.className = 'auth-dot ok'; | |
| text.textContent = 'Google configured'; | |
| btn.className = 'auth-btn connected'; | |
| btn.innerHTML = '<i class="ti ti-circle-check"></i> Connected'; | |
| btn.removeAttribute('href'); | |
| btn.onclick = null; | |
| } else { | |
| banner.className = 'auth-banner bad'; | |
| dot.className = 'auth-dot bad'; | |
| text.textContent = 'Google not connected'; | |
| btn.className = 'auth-btn'; | |
| btn.innerHTML = '<i class="ti ti-brand-google"></i> Connect Google'; | |
| btn.href = '/auth/start'; | |
| btn.onclick = onAuthClick; | |
| } | |
| } | |
| function onAuthClick() { | |
| // After OAuth window closes, recheck | |
| setTimeout(() => { | |
| const check = setInterval(async () => { | |
| const res = await fetch('/auth/status'); | |
| const data = await res.json(); | |
| if (data.authenticated) { clearInterval(check); setAuthBanner(true); } | |
| }, 20000); | |
| // Stop checking after 2 min | |
| setTimeout(() => clearInterval(check), 120000); | |
| }, 30000); | |
| } | |
| /* ── Helpers ── */ | |
| function showErr(msg) { | |
| const b = document.getElementById('err-box'); | |
| b.textContent = msg; b.style.display = 'block'; | |
| } | |
| function clearErr() { document.getElementById('err-box').style.display = 'none'; } | |
| /* ── Submit ── */ | |
| async function submitJob() { | |
| clearErr(); | |
| const url = document.getElementById('yt-url').value.trim(); | |
| const email = document.getElementById('email').value.trim(); | |
| if (!url) { showErr('Please enter a YouTube URL.'); return; } | |
| if (!email) { showErr('Please enter an email address.'); return; } | |
| const btn = document.getElementById('submit-btn'); | |
| btn.disabled = true; | |
| btn.innerHTML = '<span class="spinner"></span> Starting…'; | |
| try { | |
| const res = await fetch('/generate', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ youtube_url: url, email_to: email }), | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) { | |
| showErr(data.detail || 'Request failed.'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<i class="ti ti-player-play"></i> Start processing'; | |
| return; | |
| } | |
| startPolling(data.job_id); | |
| } catch (e) { | |
| showErr('Could not reach the server. Is it running on localhost:8000?'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<i class="ti ti-player-play"></i> Start processing'; | |
| } | |
| } | |
| /* ── Polling ── */ | |
| function startPolling(jobId) { | |
| document.getElementById('status-box').style.display = 'block'; | |
| renderSteps({}); | |
| pollTimer = setInterval(() => poll(jobId), 2500); | |
| } | |
| async function poll(jobId) { | |
| try { | |
| const res = await fetch('/status/' + jobId); | |
| const data = await res.json(); | |
| renderSteps(data.steps || {}); | |
| updateBadge(data.status); | |
| const running = Object.entries(data.steps || {}).find(([, v]) => v === 'running'); | |
| if (running) document.getElementById('status-label').textContent = STEP_LABELS[running[0]] + '…'; | |
| if (data.status === 'completed') { | |
| clearInterval(pollTimer); | |
| document.getElementById('status-label').textContent = 'Done!'; | |
| showResult(data.result); | |
| const btn = document.getElementById('submit-btn'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<i class="ti ti-player-play"></i> Process another'; | |
| } else if (data.status === 'failed') { | |
| clearInterval(pollTimer); | |
| document.getElementById('status-label').textContent = 'Failed'; | |
| showErr('Pipeline failed: ' + (data.error || 'unknown error')); | |
| const btn = document.getElementById('submit-btn'); | |
| btn.disabled = false; | |
| btn.innerHTML = '<i class="ti ti-player-play"></i> Try again'; | |
| } | |
| } catch (e) {} | |
| } | |
| /* ── Render steps ── */ | |
| function renderSteps(steps) { | |
| const list = document.getElementById('steps-list'); | |
| list.innerHTML = Object.entries(STEP_LABELS).map(([key, label]) => { | |
| const state = steps[key] || 'pending'; | |
| const iconCls = STEP_ICONS[key] || 'ti-circle'; | |
| let inner = ''; | |
| if (state === 'done') inner = '<i class="ti ti-check"></i>'; | |
| else if (state === 'running') inner = '<span class="spinner"></span>'; | |
| else if (state === 'failed') inner = '<i class="ti ti-x"></i>'; | |
| return `<div class="step ${state !== 'pending' ? 'active' : ''}"> | |
| <div class="step-icon ${state}">${inner}</div> | |
| <i class="ti ${iconCls}" style="font-size:14px;color:#aaa"></i> | |
| <span>${label}</span> | |
| </div>`; | |
| }).join(''); | |
| } | |
| function updateBadge(status) { | |
| const b = document.getElementById('status-badge'); | |
| b.className = 'badge'; | |
| if (status === 'completed') { b.classList.add('badge-done'); b.textContent = 'Completed'; } | |
| else if (status === 'failed') { b.classList.add('badge-failed'); b.textContent = 'Failed'; } | |
| else { b.classList.add('badge-running'); b.textContent = 'Running'; } | |
| } | |
| /* ── Show result links ── */ | |
| function showResult(result) { | |
| if (!result) return; | |
| const box = document.getElementById('result-box'); | |
| box.style.display = 'flex'; | |
| const drive = result.drive || {}; | |
| const links = [ | |
| ['ti-file-text', 'Summary', drive.summary?.web_view_link], | |
| ['ti-help-circle', 'Q&A', drive.qa?.web_view_link], | |
| ['ti-align-left', 'Transcript', drive.transcript?.web_view_link], | |
| ]; | |
| const method = result.extraction_method || ''; | |
| const methodLabel = method ? `<span style="display:inline-block;font-size:11px;padding:2px 8px;border-radius:12px;background:#f0f4ff;color:#3b5998;border:1px solid #c4d3f0;margin-left:4px">${method}</span>` : ''; | |
| box.innerHTML = links | |
| .filter(([,, u]) => u) | |
| .map(([icon, label, u]) => ` | |
| <div class="result-link"> | |
| <i class="ti ${icon}" style="font-size:15px;color:#888"></i> | |
| <a href="${u}" target="_blank">${label} <i class="ti ti-external-link" style="font-size:11px"></i></a> | |
| </div>`) | |
| .join('') + | |
| `<div class="result-note" style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;"> | |
| <span><i class="ti ti-mail" style="font-size:13px;vertical-align:-2px"></i> Results also sent to your email</span> | |
| ${methodLabel ? '<span style="color:#999">·</span> Extracted via ' + methodLabel : ''} | |
| </div>`; | |
| } | |
| /* ── Init ── */ | |
| checkAuth(); | |
| </script> | |
| </body> | |
| </html> |