From 0682d2d80649a8bcb4417a9f66f75235b83daed1 Mon Sep 17 00:00:00 2001 From: urieljareth Date: Sun, 26 Jul 2026 23:39:05 -0600 Subject: [PATCH] feat: batch .md download, multi-select, thumbnails, all OPPORTUNITIES tools Videos view: multi-select (checkboxes) + select-all/deselect-all + bulk action bar (Download .md batch, Download thumbnails, Download audio). Per-video buttons: .md download (done) or Process to .md (pending), clip. Channels: per-channel pending count + 'Download pending .md' (batches pending video_ids). Each channel keeps Videos/.md folder/Remove actions. Backend (jobs.py + api.py): - POST /api/scrape/batch {video_ids} + POST /api/scrape/video/{id} (on-demand .md for any video incl. pending) as SSE-tracked jobs - GET /api/videos/{id}/markdown (file download) - POST /api/tools/thumbnails + GET /api/thumbnails/{id} (local cache, offline) - GET /api/clip/{id}?from=&to= (transcript segment) - GET /api/stats (aggregate totals) - POST /api/tools/audio (mp3 job) Tools view rebuilt as full Knowledge Base grid surfacing every OPPORTUNITIES.md feature as electable buttons (md download, re-render, export, search, analysis, thumbnails, audio, clip, stats, cookies, channels, watch, open folders). Floating job widget (SSE) for any running job. Thumbnails served via /api/thumbnails/{id} everywhere (works offline once cached). Launcher: robust Open-Browser helper (3 fallback methods) auto-opens http://127.0.0.1: on start + on idempotent re-open. data/ gitignored. --- .gitignore | 8 +- scripts/start-server.ps1 | 22 +- src/yt_scraper/webapp/api.py | 115 +++++++++ src/yt_scraper/webapp/jobs.py | 96 ++++++++ src/yt_scraper/webapp/static/app.js | 249 ++++++++++++++++++- src/yt_scraper/webapp/static/index.html | 314 ++++++++++++++++++++++-- src/yt_scraper/webapp/static/styles.css | 81 ++++++ 7 files changed, 852 insertions(+), 33 deletions(-) diff --git a/.gitignore b/.gitignore index 43135cf..d1fc9d2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,13 +7,7 @@ dist/ .pytest_cache/ .coverage htmlcov/ -data/state.db -data/state.db-journal -data/state.db-wal -data/markdown/ -data/exports/ -data/analysis/ -data/audio/ +data/ cookies/ .run/ .venv/ diff --git a/scripts/start-server.ps1 b/scripts/start-server.ps1 index d1f9a89..f1c8e6f 100644 --- a/scripts/start-server.ps1 +++ b/scripts/start-server.ps1 @@ -2,6 +2,24 @@ $ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot Set-Location -LiteralPath $root +# Robust browser opener — tries several Windows methods until one works. +function Open-Browser($url) { + $opened = $false + # Method 1: Start-Process with the URL (default protocol handler) + try { Start-Process -FilePath $url; $opened = $true } catch {} + if (-not $opened) { + # Method 2: explorer.exe with the URL (opens default browser) + try { & explorer.exe $url; $opened = $true } catch {} + } + if (-not $opened) { + # Method 3: cmd `start` builtin + try { & cmd.exe /c start "" $url; $opened = $true } catch {} + } + if ($opened) { Write-Host " [ok] abriendo navegador -> $url" -ForegroundColor DarkGray } + else { Write-Host " [warn] no se pudo abrir el navegador. Abre manualmente: $url" -ForegroundColor Yellow } + return $opened +} + Write-Host "" Write-Host " [yt-scraper] iniciando servidor local..." -ForegroundColor Cyan @@ -15,7 +33,7 @@ if (Test-Path '.run\server.info') { $r = Invoke-WebRequest -Uri "http://localhost:$existingPort/healthz" -UseBasicParsing -TimeoutSec 2 if ($r.Content -match 'ok') { Write-Host " [ok] servidor ya activo en el puerto $existingPort" -ForegroundColor Green - Start-Process "http://localhost:$existingPort" + Open-Browser "http://127.0.0.1:$existingPort" exit 0 } } catch {} @@ -74,7 +92,7 @@ if (-not $launched) { exit 3 } -Start-Process "http://localhost:$port" +Open-Browser "http://127.0.0.1:$port" Write-Host "" Write-Host " [ok] servidor activo" -ForegroundColor Green Write-Host " URL: http://localhost:$port" diff --git a/src/yt_scraper/webapp/api.py b/src/yt_scraper/webapp/api.py index 053f607..bc82ee4 100644 --- a/src/yt_scraper/webapp/api.py +++ b/src/yt_scraper/webapp/api.py @@ -301,6 +301,121 @@ def build_router(store: Store, cfg: Config, jobs) -> APIRouter: "output_dir": str(Path(cfg.output_dir_resolved)), } + # -------------------------------------------------- per-video / batch markdown + @r.post("/scrape/video/{video_id}") + def scrape_one_video(video_id: str, payload: dict | None = None): + opts = (payload or {}).get("opts", {}) or {} + opts["video_ids"] = [video_id] + job_id = jobs.enqueue(None, opts) + return {"job_id": job_id} + + @r.post("/scrape/batch") + async def scrape_batch(payload: dict): + video_ids = (payload or {}).get("video_ids") or [] + if not video_ids: + raise HTTPException(400, "video_ids required") + opts = (payload or {}).get("opts", {}) or {} + opts["video_ids"] = list(video_ids) + job_id = jobs.enqueue(None, opts) + return {"job_id": job_id, "count": len(video_ids)} + + @r.get("/videos/{video_id}/markdown") + def video_markdown(video_id: str): + v = store.get_video(video_id) + if not v or not v.markdown_path: + raise HTTPException(404, "markdown not generated yet") + p = Path(cfg.output_dir_resolved).parent / v.markdown_path + if not p.exists(): + raise HTTPException(404, "markdown file missing on disk") + return FileResponse(str(p), filename=p.name, media_type="text/markdown") + + # -------------------------------------------------- thumbnails (local cache) + @r.post("/tools/thumbnails") + async def download_thumbnails(payload: dict): + import requests as _req + video_ids = (payload or {}).get("video_ids") or [] + channel_id = (payload or {}).get("channel_id") + if channel_id and not video_ids: + video_ids = [v.video_id for v in store.get_all(channel_id) if v.thumbnail] + out_dir = Path(cfg.output_dir_resolved).parent / "thumbnails" + out_dir.mkdir(parents=True, exist_ok=True) + n = 0 + for vid in video_ids: + v = store.get_video(vid) + if not v or not v.thumbnail: + continue + target = out_dir / f"{vid}.jpg" + if target.exists(): + n += 1 + continue + try: + r2 = _req.get(v.thumbnail, timeout=15, headers={"User-Agent": "Mozilla/5.0"}) + r2.raise_for_status() + target.write_bytes(r2.content) + n += 1 + except Exception: + continue + return {"downloaded": n, "dir": str(out_dir)} + + @r.get("/thumbnails/{video_id}") + def serve_thumbnail(video_id: str): + from fastapi.responses import RedirectResponse + local = Path(cfg.output_dir_resolved).parent / "thumbnails" / f"{video_id}.jpg" + if local.exists(): + return FileResponse(str(local), media_type="image/jpeg") + v = store.get_video(video_id) + if v and v.thumbnail: + return RedirectResponse(url=v.thumbnail, status_code=302) + raise HTTPException(404, "no thumbnail") + + # -------------------------------------------------- clip (transcript segment) + @r.get("/clip/{video_id}") + def clip_video(video_id: str, frm: str = Query("0:00", alias="from"), to: str = Query("", alias="to")): + from ..segments import ts_to_seconds + segs = store.get_segments(video_id) + if not segs: + raise HTTPException(404, "no transcript for this video") + start = ts_to_seconds(frm) + end = ts_to_seconds(to) if to else float("inf") + picked = [s for s in segs if start <= s.start_sec < end] + text = "\n".join(f"[{int(s.start_sec//60):02d}:{int(s.start_sec%60):02d}] {s.text}" for s in picked) + v = store.get_video(video_id) + return { + "video_id": video_id, + "title": v.title if v else "", + "from": frm, "to": to or "end", + "segment_count": len(picked), + "text": text, + } + + # -------------------------------------------------- stats (per channel / all) + @r.get("/stats") + def stats(channel: str | None = None): + d = store.dashboard() + if channel: + d["channels"] = [c for c in d["channels"] if c["channel_id"] == channel] + totals = { + "videos": sum((c.get("video_count_db") or 0) for c in d["channels"]), + "duration_sec": sum((c.get("total_duration") or 0) for c in d["channels"]), + "views": sum((c.get("total_views") or 0) for c in d["channels"]), + "likes": sum((c.get("total_likes") or 0) for c in d["channels"]), + } + return {"channels": d["channels"], "status_breakdown": d["status_breakdown"], + "top_tags": d["top_tags"], "totals": totals} + + # -------------------------------------------------- audio (job) + @r.post("/tools/audio") + async def tools_audio(payload: dict): + video_ids = (payload or {}).get("video_ids") or [] + channel_id = (payload or {}).get("channel_id") + if not video_ids and not channel_id: + raise HTTPException(400, "video_ids or channel_id required") + opts = {"mode": "audio", "channel_id": channel_id} + if video_ids: + opts["video_ids"] = list(video_ids) + job_id = jobs.enqueue(channel_id, opts) + return {"job_id": job_id} + return r diff --git a/src/yt_scraper/webapp/jobs.py b/src/yt_scraper/webapp/jobs.py index 23017f1..879b3fe 100644 --- a/src/yt_scraper/webapp/jobs.py +++ b/src/yt_scraper/webapp/jobs.py @@ -89,6 +89,102 @@ class JobManager: if not job: return opts = json.loads(job.opts_json) if job.opts_json else {} + if opts.get("video_ids"): + self._run_batch(job_id, opts) + return + if opts.get("mode") == "audio": + self._run_audio(job_id, opts) + return + self._run_channel(job_id, opts) + + def _resolve_channel_for_video(self, video_id: str) -> tuple[str, str, str]: + row = self.store.get_video(video_id) + if not row: + return ("unknown", "", f"https://www.youtube.com/watch?v={video_id}") + ch = self.store.get_channel(row.channel_id) or {} + name = ch.get("name") or "unknown" + handle = (ch.get("handle") or "").lstrip("@") + url = f"https://www.youtube.com/@{handle}/videos" if handle else row.url + return (name, row.channel_id, url) + + def _run_batch(self, job_id: str, opts: dict[str, Any]) -> None: + video_ids = opts.get("video_ids") or [] + cookie_path = opts.get("cookies_file") or resolve_active_path(self.store) + total = len(video_ids) + self.store.update_job(job_id, status="running", total=total, completed=0) + self._emit(job_id, "progress", {"completed": 0, "total": total}) + self._emit(job_id, "log", {"msg": f"processing {total} videos"}) + cfg = _clone_config(self.cfg) + if opts.get("languages"): + cfg.languages = opts["languages"] + completed = 0 + for vid in video_ids: + if job_id in self._cancel: + self.store.update_job(job_id, status="cancelled", completed=completed, finished=True) + self._emit(job_id, "cancelled", {"completed": completed, "total": total}) + return + row = self.store.get_video(vid) + if not row: + self._emit(job_id, "log", {"msg": f"skip unknown {vid}"}) + completed += 1 + self.store.update_job(job_id, completed=completed) + continue + channel_name, channel_id, channel_url = self._resolve_channel_for_video(vid) + status = process_video( + row, cfg, self.store, channel_name, channel_id, channel_url, + cookies_file=cookie_path, + on_log=lambda m: self._emit(job_id, "log", {"msg": m}), + ) + completed += 1 + self.store.update_job(job_id, completed=completed) + self._emit(job_id, "progress", {"completed": completed, "total": total, "video_id": vid, "status": status}) + self.store.update_job(job_id, status="done", completed=completed, finished=True) + self._emit(job_id, "done", {"completed": completed, "total": total}) + + def _run_audio(self, job_id: str, opts: dict[str, Any]) -> None: + import yt_dlp + video_ids = opts.get("video_ids") or [] + channel_id = opts.get("channel_id") + cookie_path = opts.get("cookies_file") or resolve_active_path(self.store) + if video_ids: + videos = [self.store.get_video(v) for v in video_ids if self.store.get_video(v)] + else: + videos = [v for v in self.store.get_all(channel_id) if v.status == "done"] + out_dir = Path(self.cfg.output_dir_resolved).parent / "audio" + out_dir.mkdir(parents=True, exist_ok=True) + total = len(videos) + self.store.update_job(job_id, status="running", total=total, completed=0) + self._emit(job_id, "log", {"msg": f"downloading {total} audio tracks -> {out_dir}"}) + ydl_opts = { + "format": "bestaudio/best", + "outtmpl": str(out_dir / "%(title)s.%(ext)s"), + "postprocessors": [{"key": "FFmpegExtractAudio", "preferredcodec": "mp3", "preferredquality": "128"}], + "quiet": True, "no_warnings": True, "noprogress": True, + } + if cookie_path: + ydl_opts["cookiefile"] = cookie_path + completed = 0 + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + for v in videos: + if job_id in self._cancel: + self.store.update_job(job_id, status="cancelled", completed=completed, finished=True) + self._emit(job_id, "cancelled", {"completed": completed, "total": total}) + return + try: + ydl.download([v.url]) + self._emit(job_id, "log", {"msg": f"OK {v.video_id}"}) + except Exception as exc: + self._emit(job_id, "log", {"msg": f"FAIL {v.video_id}: {exc}"}) + completed += 1 + self.store.update_job(job_id, completed=completed) + self._emit(job_id, "progress", {"completed": completed, "total": total, "video_id": v.video_id}) + self.store.update_job(job_id, status="done", completed=completed, finished=True) + self._emit(job_id, "done", {"completed": completed, "total": total}) + + def _run_channel(self, job_id: str, opts: dict[str, Any]) -> None: + job = self.store.get_job(job_id) + if not job: + return channel_id = job.channel_id cfg = _clone_config(self.cfg) channel_url = _resolve_channel_url(self.store, cfg, channel_id) diff --git a/src/yt_scraper/webapp/static/app.js b/src/yt_scraper/webapp/static/app.js index 15cd331..17626e0 100644 --- a/src/yt_scraper/webapp/static/app.js +++ b/src/yt_scraper/webapp/static/app.js @@ -35,8 +35,8 @@ // ----- data stores ----- dash: { channels: [], status_breakdown: {}, uploads_over_time: [], duration_histogram: [], top_tags: [] }, - channels: { items: [] }, - videos: { items: [], total: 0, page: 1, size: 25 }, + channels: { items: [], pending: {} }, + videos: { items: [], total: 0, page: 1, size: 25, selected: [] }, detail: { video: null, transcript: [] }, search: { q: "", channel: "", items: [], ran: false }, analysis: { tab: "wordcloud", channel: "", term: "", words: [], timeline: [] }, @@ -44,12 +44,22 @@ scrape: { form: { channel_id: "", limit: "", since: "", languages: "", include_shorts: false, no_live: false }, jobId: null, progress: { completed: 0, total: 0 }, log: [], jobs: [], es: null, done: false, error: null, + kind: "", }, cookies: { items: [], drag: false, testResult: {}, uploadMsg: "" }, + // ----- clip mini-tool ----- + clip: { open: false, videoId: "", from: "", to: "", result: null, loading: false, error: "" }, + + // ----- stats panel ----- + stats: { open: false, data: null, loading: false, channel: "" }, + + // ----- floating job widget ----- + jobWidget: { collapsed: false }, + // ----- tools / folders ----- folders: { markdown: { path: "", exists: false }, exports: { path: "", exists: false }, audio: { path: "", exists: false }, analysis: { path: "", exists: false }, database: { path: "", exists: false } }, - tools: { reRendering: false, msg: "", fmt: null }, + tools: { reRendering: false, msg: "", fmt: null, thumbScope: "all", audioScope: "all", busy: "", watchCmd: "" }, // ----- forms ----- forms: { channelUrl: "", channelError: "", exportFormat: "json", exportChannel: "" }, @@ -141,11 +151,29 @@ // ================================================================= async loadChannels() { this.loading.channels = true; - try { const d = await this.api("/api/channels"); this.channels = d; } + try { + const d = await this.api("/api/channels"); + // preserve pending cache, default to {} + const prevPending = (this.channels && this.channels.pending) || {}; + this.channels = d; + this.channels.pending = prevPending; + // lazy-load pending counts for each channel + for (const c of (this.channels.items || [])) { + this.loadChannelPending(c.channel_id); + } + } catch (e) { this.toast("Failed to load channels: " + e.message, "error"); } finally { this.loading.channels = false; } }, + async loadChannelPending(id) { + try { + const d = await this.api("/api/videos?channel=" + encodeURIComponent(id) + "&status=pending&size=1"); + if (!this.channels.pending) this.channels.pending = {}; + this.channels.pending[id] = (d && d.total) || 0; + } catch (_) {} + }, + async addChannel() { const url = (this.forms.channelUrl || "").trim(); if (!url) return; @@ -217,6 +245,200 @@ if (best) { const el = document.getElementById("seg-" + best.idx); if (el) el.scrollIntoView({ behavior: "smooth", block: "center" }); } }, + // ================================================================= + // selection (videos view) + // ================================================================= + toggleSelect(id) { + const i = this.videos.selected.indexOf(id); + if (i >= 0) this.videos.selected.splice(i, 1); + else this.videos.selected.push(id); + }, + isSelected(id) { return this.videos.selected.indexOf(id) >= 0; }, + selectAll() { + this.videos.selected = (this.videos.items || []).map(v => v.video_id); + }, + selectNone() { this.videos.selected = []; }, + get allSelected() { + const items = this.videos.items || []; + return items.length > 0 && items.every(v => this.videos.selected.indexOf(v.video_id) >= 0); + }, + toggleSelectAll() { if (this.allSelected) this.selectNone(); else this.selectAll(); }, + + // ================================================================= + // downloads (.md / thumbnails / audio / single) + // ================================================================= + // trigger a browser download via a hidden anchor (for GET endpoints) + downloadFile(url) { + const a = document.createElement("a"); + a.href = url; + a.setAttribute("download", ""); + a.style.display = "none"; + document.body.appendChild(a); + a.click(); + setTimeout(() => { try { a.remove(); } catch (_) {} }, 200); + }, + + copyText(text) { + if (!text) return; + try { + navigator.clipboard.writeText(text).then(() => this.toast("Copied to clipboard")); + } catch (_) { this.toast("Copy failed", "error"); } + }, + + downloadMd(videoId) { + this.downloadFile("/api/videos/" + encodeURIComponent(videoId) + "/markdown"); + }, + + async processOne(videoId) { + try { + const d = await this.api("/api/scrape/video/" + encodeURIComponent(videoId), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}) }); + this.toast("Queued video for .md generation"); + this.subscribeJob(d.job_id, "video"); + } catch (e) { this.toast("Process failed: " + e.message, "error"); } + }, + + async downloadMdBatch(videoIds) { + const ids = (videoIds || this.videos.selected || []).slice(); + if (!ids.length) { this.toast("No videos selected", "error"); return; } + try { + const d = await this.api("/api/scrape/batch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ video_ids: ids }) }); + this.toast("Batch .md job started: " + (d.count || ids.length) + " videos"); + this.subscribeJob(d.job_id, "batch"); + if (!videoIds) this.selectNone(); + } catch (e) { this.toast("Batch failed: " + e.message, "error"); } + }, + + async downloadThumbnails(videoIds) { + const ids = (videoIds || this.videos.selected || []).slice(); + if (!ids.length) { this.toast("No videos selected", "error"); return; } + try { + const d = await this.api("/api/tools/thumbnails", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ video_ids: ids }) }); + this.toast("Downloaded " + (d.downloaded || 0) + " thumbnail(s)"); + } catch (e) { this.toast("Thumbnails failed: " + e.message, "error"); } + }, + + async downloadAudio(videoIds) { + const ids = (videoIds || this.videos.selected || []).slice(); + if (!ids.length) { this.toast("No videos selected", "error"); return; } + try { + const d = await this.api("/api/tools/audio", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ video_ids: ids }) }); + this.toast("Audio job started: " + (d.job_id || "").slice(0, 8)); + this.subscribeJob(d.job_id, "audio"); + } catch (e) { this.toast("Audio failed: " + e.message, "error"); } + }, + + // scope-driven variant for the Tools cards: scope = 'all' | 'selection' | channel_id + async downloadThumbnailsScope(scope) { + const body = {}; + if (scope === "selection") { + if (!this.videos.selected.length) { this.toast("No videos selected", "error"); return; } + body.video_ids = this.videos.selected.slice(); + } else if (scope && scope !== "all") { + body.channel_id = scope; + } + this.tools.busy = "thumbnails"; + try { + const d = await this.api("/api/tools/thumbnails", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + this.toast("Downloaded " + (d.downloaded || 0) + " thumbnail(s)"); + } catch (e) { this.toast("Thumbnails failed: " + e.message, "error"); } + finally { this.tools.busy = ""; } + }, + + async downloadAudioScope(scope) { + const body = {}; + if (scope === "selection") { + if (!this.videos.selected.length) { this.toast("No videos selected", "error"); return; } + body.video_ids = this.videos.selected.slice(); + } else if (scope && scope !== "all") { + body.channel_id = scope; + } + this.tools.busy = "audio"; + try { + const d = await this.api("/api/tools/audio", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + this.toast("Audio job started"); + this.subscribeJob(d.job_id, "audio"); + } catch (e) { this.toast("Audio failed: " + e.message, "error"); } + finally { this.tools.busy = ""; } + }, + + // ================================================================= + // clip mini-tool + // ================================================================= + openClip(videoId) { + this.clip.open = true; + this.clip.videoId = videoId || ""; + this.clip.from = ""; + this.clip.to = ""; + this.clip.result = null; + this.clip.error = ""; + }, + closeClip() { + this.clip.open = false; + this.clip.result = null; + this.clip.error = ""; + }, + async runClip() { + if (!this.clip.videoId || !this.clip.from || !this.clip.to) { + this.clip.error = "Video, from and to are required."; return; + } + this.clip.loading = true; + this.clip.error = ""; + this.clip.result = null; + try { + const p = new URLSearchParams({ from: this.clip.from, to: this.clip.to }); + const d = await this.api("/api/clip/" + encodeURIComponent(this.clip.videoId) + "?" + p.toString()); + this.clip.result = d; + } catch (e) { this.clip.error = e.message; } + finally { this.clip.loading = false; } + }, + copyClip() { + if (!this.clip.result || !this.clip.result.text) return; + try { + navigator.clipboard.writeText(this.clip.result.text).then(() => this.toast("Copied to clipboard")); + } catch (_) { this.toast("Copy failed", "error"); } + }, + downloadClipTxt() { + if (!this.clip.result || !this.clip.result.text) return; + const r = this.clip.result; + const head = "# " + (r.title || r.video_id) + "\n" + r.from + " → " + r.to + "\n\n"; + const blob = new Blob([head + r.text], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + this.downloadFile(url); + setTimeout(() => URL.revokeObjectURL(url), 1500); + }, + + // ================================================================= + // stats panel + // ================================================================= + async loadStats(channel) { + this.stats.open = true; + this.stats.loading = true; + this.stats.channel = channel || ""; + this.stats.data = null; + try { + const p = new URLSearchParams(); + if (channel) p.set("channel", channel); + const d = await this.api("/api/stats?" + p.toString()); + this.stats.data = d; + } catch (e) { this.toast("Stats failed: " + e.message, "error"); } + finally { this.stats.loading = false; } + }, + closeStats() { this.stats.open = false; }, + + // ================================================================= + // channels: pending download + // ================================================================= + async downloadChannelPending(channelId) { + try { + const d = await this.api("/api/videos?channel=" + encodeURIComponent(channelId) + "&status=pending&size=1000"); + const ids = ((d && d.items) || []).map(v => v.video_id); + if (!ids.length) { this.toast("No pending videos for this channel"); return; } + const j = await this.api("/api/scrape/batch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ video_ids: ids }) }); + this.toast("Processing " + (j.count || ids.length) + " pending videos"); + this.subscribeJob(j.job_id, "batch"); + } catch (e) { this.toast("Pending download failed: " + e.message, "error"); } + }, + // ================================================================= // search // ================================================================= @@ -322,21 +544,34 @@ try { const d = await this.api("/api/scrape", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); this.scrape.done = false; this.scrape.error = null; - this.subscribeJob(d.job_id); + this.subscribeJob(d.job_id, "scrape"); } catch (e) { this.toast("Scrape failed: " + e.message, "error"); } finally { this.loading.startScrape = false; } }, - subscribeJob(jobId) { + subscribeJob(jobId, kind) { this.closeStream(); this.scrape.jobId = jobId; + this.scrape.kind = kind || "job"; + this.scrape.done = false; this.scrape.error = null; this.scrape.log = ["[stream] connecting…"]; this.scrape.progress = { completed: 0, total: 0 }; + this.jobWidget.collapsed = false; const es = new EventSource("/api/scrape/" + encodeURIComponent(jobId) + "/stream"); this.scrape.es = es; es.addEventListener("log", e => { try { const d = JSON.parse(e.data); this.scrape.log.push(d.msg || ""); } catch (_) {} }); es.addEventListener("progress", e => { try { const d = JSON.parse(e.data); this.scrape.progress = d; } catch (_) {} }); - es.addEventListener("done", () => { this.scrape.done = true; this.scrape.log.push("[done] job finished"); this.closeStream(); this.loadJobs(); }); + es.addEventListener("done", () => { + this.scrape.done = true; + this.scrape.log.push("[done] job finished"); + this.closeStream(); + this.loadJobs(); + this.toast((this.scrape.kind === "audio" ? "Audio job complete" : "Job complete — .md ready"), "success"); + // refresh current view data so statuses/counts update + if (this.view === "videos") this.loadVideos(); + if (this.view === "channels") this.loadChannels(); + if (this.view === "dashboard") this.loadDashboard(); + }); es.addEventListener("cancelled", () => { this.scrape.log.push("[cancelled]"); this.closeStream(); this.loadJobs(); }); es.addEventListener("error", e => { if (this.scrape.es === null) return; // already closed by done/cancelled diff --git a/src/yt_scraper/webapp/static/index.html b/src/yt_scraper/webapp/static/index.html index 0391c03..083cdda 100644 --- a/src/yt_scraper/webapp/static/index.html +++ b/src/yt_scraper/webapp/static/index.html @@ -162,19 +162,23 @@
- + - - + +
NameHandleVideosLast scrapedActions
NameHandleVideosPendingLast scrapedActions