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 @@
- | Name | Handle | Videos | Last scraped | Actions |
+ | Name | Handle | Videos | Pending | Last scraped | Actions |
- | loading… |
- | No channels tracked yet. |
+ | loading… |
+ | No channels tracked yet. |
|
|
|
+
+
+ |
|
+
|
@@ -205,27 +209,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
Thumb | Title | Channel |
|
- Duration | Views | Status |
+ Duration | Views | Status | Actions |
- | loading… |
- | No videos match these filters. |
+ | loading… |
+ | No videos match these filters. |
-
- ![]() |
+
+ |
+ ![]() |
|
|
|
|
|
|
+
+
+
+ |
@@ -251,7 +283,7 @@
-
![]()
+
+
@@ -524,27 +561,119 @@
-
-
Re-render Markdown
+
+
+
-
-
Export catalog
+
+
-
-
Cookie vault
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -622,6 +751,157 @@
+
+
+
+
+
+
+
+
+
+
+
+
Clip extractor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ·
+ ·
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Stats
+
+
+
+
crunching…
+
+
+
+
+
+
+
Status breakdown
+
+
+
+
+
+
+
+
+
+
Per channel
+
+
+ | Channel | Videos | Duration | Views |
+
+
+ | | | |
+
+
+
+
+
+
+
+
+
+
+
+
+
+