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:<port> on start + on idempotent re-open. data/ gitignored.
This commit is contained in:
urieljareth
2026-07-26 23:39:05 -06:00
parent 621bbc5f5c
commit 0682d2d806
7 changed files with 852 additions and 33 deletions
+1 -7
View File
@@ -7,13 +7,7 @@ dist/
.pytest_cache/ .pytest_cache/
.coverage .coverage
htmlcov/ htmlcov/
data/state.db data/
data/state.db-journal
data/state.db-wal
data/markdown/
data/exports/
data/analysis/
data/audio/
cookies/ cookies/
.run/ .run/
.venv/ .venv/
+20 -2
View File
@@ -2,6 +2,24 @@ $ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot $root = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $root 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 ""
Write-Host " [yt-scraper] iniciando servidor local..." -ForegroundColor Cyan 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 $r = Invoke-WebRequest -Uri "http://localhost:$existingPort/healthz" -UseBasicParsing -TimeoutSec 2
if ($r.Content -match 'ok') { if ($r.Content -match 'ok') {
Write-Host " [ok] servidor ya activo en el puerto $existingPort" -ForegroundColor Green 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 exit 0
} }
} catch {} } catch {}
@@ -74,7 +92,7 @@ if (-not $launched) {
exit 3 exit 3
} }
Start-Process "http://localhost:$port" Open-Browser "http://127.0.0.1:$port"
Write-Host "" Write-Host ""
Write-Host " [ok] servidor activo" -ForegroundColor Green Write-Host " [ok] servidor activo" -ForegroundColor Green
Write-Host " URL: http://localhost:$port" Write-Host " URL: http://localhost:$port"
+115
View File
@@ -301,6 +301,121 @@ def build_router(store: Store, cfg: Config, jobs) -> APIRouter:
"output_dir": str(Path(cfg.output_dir_resolved)), "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 return r
+96
View File
@@ -89,6 +89,102 @@ class JobManager:
if not job: if not job:
return return
opts = json.loads(job.opts_json) if job.opts_json else {} 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 channel_id = job.channel_id
cfg = _clone_config(self.cfg) cfg = _clone_config(self.cfg)
channel_url = _resolve_channel_url(self.store, cfg, channel_id) channel_url = _resolve_channel_url(self.store, cfg, channel_id)
+242 -7
View File
@@ -35,8 +35,8 @@
// ----- data stores ----- // ----- data stores -----
dash: { channels: [], status_breakdown: {}, uploads_over_time: [], duration_histogram: [], top_tags: [] }, dash: { channels: [], status_breakdown: {}, uploads_over_time: [], duration_histogram: [], top_tags: [] },
channels: { items: [] }, channels: { items: [], pending: {} },
videos: { items: [], total: 0, page: 1, size: 25 }, videos: { items: [], total: 0, page: 1, size: 25, selected: [] },
detail: { video: null, transcript: [] }, detail: { video: null, transcript: [] },
search: { q: "", channel: "", items: [], ran: false }, search: { q: "", channel: "", items: [], ran: false },
analysis: { tab: "wordcloud", channel: "", term: "", words: [], timeline: [] }, analysis: { tab: "wordcloud", channel: "", term: "", words: [], timeline: [] },
@@ -44,12 +44,22 @@
scrape: { scrape: {
form: { channel_id: "", limit: "", since: "", languages: "", include_shorts: false, no_live: false }, 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, jobId: null, progress: { completed: 0, total: 0 }, log: [], jobs: [], es: null, done: false, error: null,
kind: "",
}, },
cookies: { items: [], drag: false, testResult: {}, uploadMsg: "" }, 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 ----- // ----- tools / folders -----
folders: { markdown: { path: "", exists: false }, exports: { path: "", exists: false }, audio: { path: "", exists: false }, analysis: { path: "", exists: false }, database: { path: "", exists: false } }, 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 -----
forms: { channelUrl: "", channelError: "", exportFormat: "json", exportChannel: "" }, forms: { channelUrl: "", channelError: "", exportFormat: "json", exportChannel: "" },
@@ -141,11 +151,29 @@
// ================================================================= // =================================================================
async loadChannels() { async loadChannels() {
this.loading.channels = true; 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"); } catch (e) { this.toast("Failed to load channels: " + e.message, "error"); }
finally { this.loading.channels = false; } 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() { async addChannel() {
const url = (this.forms.channelUrl || "").trim(); const url = (this.forms.channelUrl || "").trim();
if (!url) return; if (!url) return;
@@ -217,6 +245,200 @@
if (best) { const el = document.getElementById("seg-" + best.idx); if (el) el.scrollIntoView({ behavior: "smooth", block: "center" }); } 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 // search
// ================================================================= // =================================================================
@@ -322,21 +544,34 @@
try { try {
const d = await this.api("/api/scrape", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); 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.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"); } } catch (e) { this.toast("Scrape failed: " + e.message, "error"); }
finally { this.loading.startScrape = false; } finally { this.loading.startScrape = false; }
}, },
subscribeJob(jobId) { subscribeJob(jobId, kind) {
this.closeStream(); this.closeStream();
this.scrape.jobId = jobId; this.scrape.jobId = jobId;
this.scrape.kind = kind || "job";
this.scrape.done = false; this.scrape.error = null;
this.scrape.log = ["[stream] connecting…"]; this.scrape.log = ["[stream] connecting…"];
this.scrape.progress = { completed: 0, total: 0 }; this.scrape.progress = { completed: 0, total: 0 };
this.jobWidget.collapsed = false;
const es = new EventSource("/api/scrape/" + encodeURIComponent(jobId) + "/stream"); const es = new EventSource("/api/scrape/" + encodeURIComponent(jobId) + "/stream");
this.scrape.es = es; this.scrape.es = es;
es.addEventListener("log", e => { try { const d = JSON.parse(e.data); this.scrape.log.push(d.msg || ""); } catch (_) {} }); 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("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("cancelled", () => { this.scrape.log.push("[cancelled]"); this.closeStream(); this.loadJobs(); });
es.addEventListener("error", e => { es.addEventListener("error", e => {
if (this.scrape.es === null) return; // already closed by done/cancelled if (this.scrape.es === null) return; // already closed by done/cancelled
+297 -17
View File
@@ -162,19 +162,23 @@
<div class="glass overflow-hidden"> <div class="glass overflow-hidden">
<table class="tbl"> <table class="tbl">
<thead><tr><th>Name</th><th>Handle</th><th>Videos</th><th>Last scraped</th><th class="text-right">Actions</th></tr></thead> <thead><tr><th>Name</th><th>Handle</th><th>Videos</th><th>Pending</th><th>Last scraped</th><th class="text-right">Actions</th></tr></thead>
<tbody> <tbody>
<template x-if="loading.channels"><tr><td colspan="5" class="text-center text-zinc-500 py-8">loading…</td></tr></template> <template x-if="loading.channels"><tr><td colspan="6" class="text-center text-zinc-500 py-8">loading…</td></tr></template>
<template x-if="!loading.channels && channels.items.length===0"><tr><td colspan="5" class="text-center text-zinc-500 py-8">No channels tracked yet.</td></tr></template> <template x-if="!loading.channels && channels.items.length===0"><tr><td colspan="6" class="text-center text-zinc-500 py-8">No channels tracked yet.</td></tr></template>
<template x-for="c in channels.items" :key="c.channel_id"> <template x-for="c in channels.items" :key="c.channel_id">
<tr class="row-hover"> <tr class="row-hover">
<td><button class="text-white font-medium hover:text-rose-400 transition-colors text-left" @click="openChannel(c.channel_id)" x-text="c.name"></button></td> <td><button class="text-white font-medium hover:text-rose-400 transition-colors text-left" @click="openChannel(c.channel_id)" x-text="c.name"></button></td>
<td class="text-zinc-400" x-text="c.handle || '—'"></td> <td class="text-zinc-400" x-text="c.handle || '—'"></td>
<td><button class="font-mono text-zinc-300 hover:text-rose-400 transition-colors" @click="openChannel(c.channel_id)" x-text="fmtNum(c.video_count)"></button></td> <td><button class="font-mono text-zinc-300 hover:text-rose-400 transition-colors" @click="openChannel(c.channel_id)" x-text="fmtNum(c.video_count)"></button></td>
<td>
<span class="pill" :class="(channels.pending[c.channel_id]||0) > 0 ? 'st-pending' : 'st-skipped'" x-text="(channels.pending[c.channel_id]||0) + ' pending'"></span>
</td>
<td class="text-zinc-400 text-xs" x-text="c.last_scraped ? fmtDate(c.last_scraped) : '—'"></td> <td class="text-zinc-400 text-xs" x-text="c.last_scraped ? fmtDate(c.last_scraped) : '—'"></td>
<td class="text-right whitespace-nowrap"> <td class="text-right whitespace-nowrap">
<button class="btn btn-ghost !py-1 !px-2 text-xs" @click="openChannel(c.channel_id)">Videos</button> <button class="btn btn-ghost !py-1 !px-2 text-xs" @click="openChannel(c.channel_id)">Videos</button>
<button class="btn btn-ghost !py-1 !px-2 text-xs" @click="openFolder('markdown', c.channel_id)" title="Open .md folder">.md</button> <button class="btn btn-ghost !py-1 !px-2 text-xs" @click="openFolder('markdown', c.channel_id)" title="Open .md folder">.md</button>
<button class="btn accent-grad btn-primary !py-1 !px-2 text-xs" @click="downloadChannelPending(c.channel_id)" title="Process pending videos to .md" :disabled="(channels.pending[c.channel_id]||0)===0">Pending .md</button>
<button class="btn btn-danger !py-1 !px-2 text-xs" @click="removeChannel(c.channel_id)">Remove</button> <button class="btn btn-danger !py-1 !px-2 text-xs" @click="removeChannel(c.channel_id)">Remove</button>
</td> </td>
</tr> </tr>
@@ -205,27 +209,55 @@
</div> </div>
</div> </div>
<!-- bulk action bar -->
<template x-if="videos.selected.length > 0">
<div class="bulk-bar glass p-3 flex flex-wrap items-center gap-2">
<span class="pill accent-grad btn-primary font-bold" x-text="videos.selected.length + ' selected'"></span>
<button class="btn accent-grad btn-primary !py-1.5 !px-3 text-xs" @click="downloadMdBatch()">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4M4 18v2h16v-2"/></svg>
Download .md
</button>
<button class="btn btn-ghost !py-1.5 !px-3 text-xs" @click="downloadThumbnails()">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 7.5 7.5 12M12 7.5V18"/></svg>
Thumbnails
</button>
<button class="btn btn-ghost !py-1.5 !px-3 text-xs" @click="downloadAudio()">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 18V6l10-2v12M9 18a3 3 0 11-6 0 3 3 0 016 0zm10-2a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Audio
</button>
<div class="flex-1"></div>
<button class="btn btn-ghost !py-1.5 !px-3 text-xs" @click="toggleSelectAll()" x-text="allSelected ? 'Unselect page' : 'Select page'"></button>
<button class="btn btn-ghost !py-1.5 !px-3 text-xs" @click="selectNone()">Clear</button>
</div>
</template>
<div class="glass overflow-hidden"> <div class="glass overflow-hidden">
<table class="tbl"> <table class="tbl">
<thead><tr> <thead><tr>
<th class="w-8"><input type="checkbox" class="accent-rose-500" :checked="allSelected" @change="toggleSelectAll()" title="Select page" /></th>
<th class="w-24">Thumb</th><th>Title</th><th>Channel</th> <th class="w-24">Thumb</th><th>Title</th><th>Channel</th>
<th><select class="bg-transparent text-zinc-500 uppercase text-[0.7rem] tracking-wider font-semibold" x-model="filters.sort" @change="loadVideos(videos.page)"> <th><select class="bg-transparent text-zinc-500 uppercase text-[0.7rem] tracking-wider font-semibold" x-model="filters.sort" @change="loadVideos(videos.page)">
<option value="upload_date">Uploaded</option><option value="duration">Duration</option><option value="view_count">Views</option><option value="like_count">Likes</option> <option value="upload_date">Uploaded</option><option value="duration">Duration</option><option value="view_count">Views</option><option value="like_count">Likes</option>
</select></th> </select></th>
<th>Duration</th><th>Views</th><th>Status</th> <th>Duration</th><th>Views</th><th>Status</th><th class="text-right">Actions</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
<template x-if="loading.videos"><tr><td colspan="7" class="text-center text-zinc-500 py-10">loading…</td></tr></template> <template x-if="loading.videos"><tr><td colspan="9" class="text-center text-zinc-500 py-10">loading…</td></tr></template>
<template x-if="!loading.videos && videos.items.length===0"><tr><td colspan="7" class="text-center text-zinc-500 py-10">No videos match these filters.</td></tr></template> <template x-if="!loading.videos && videos.items.length===0"><tr><td colspan="9" class="text-center text-zinc-500 py-10">No videos match these filters.</td></tr></template>
<template x-for="v in videos.items" :key="v.video_id"> <template x-for="v in videos.items" :key="v.video_id">
<tr class="row-hover" @click="openVideo(v.video_id)"> <tr class="row-hover" :class="isSelected(v.video_id) ? 'row-selected' : ''" @click="openVideo(v.video_id)">
<td><img class="thumb w-20 h-12" :src="v.thumbnail || ''" :alt="v.title" onerror="this.style.visibility='hidden'" /></td> <td @click.stop><input type="checkbox" class="accent-rose-500" :checked="isSelected(v.video_id)" @change="toggleSelect(v.video_id)" /></td>
<td><img class="thumb w-20 h-12" :src="'/api/thumbnails/'+v.video_id" :alt="v.title" onerror="this.style.visibility='hidden'" /></td>
<td class="text-zinc-100 max-w-md truncate" x-text="v.title"></td> <td class="text-zinc-100 max-w-md truncate" x-text="v.title"></td>
<td class="text-zinc-400 text-xs" x-text="chanName(v.channel_id)"></td> <td class="text-zinc-400 text-xs" x-text="chanName(v.channel_id)"></td>
<td class="text-zinc-400 font-mono text-xs" x-text="fmtDate(v.upload_date)"></td> <td class="text-zinc-400 font-mono text-xs" x-text="fmtDate(v.upload_date)"></td>
<td class="font-mono text-zinc-300 text-xs" x-text="ts(v.duration)"></td> <td class="font-mono text-zinc-300 text-xs" x-text="ts(v.duration)"></td>
<td class="font-mono text-zinc-300 text-xs" x-text="fmtNum(v.view_count)"></td> <td class="font-mono text-zinc-300 text-xs" x-text="fmtNum(v.view_count)"></td>
<td><span class="pill" :class="statusClass(v.status)" x-text="v.status"></span></td> <td><span class="pill" :class="statusClass(v.status)" x-text="v.status"></span></td>
<td class="text-right whitespace-nowrap" @click.stop>
<button class="btn btn-ghost !py-1 !px-2 text-xs" :class="v.status==='done' ? 'md-done' : 'md-todo'" @click="v.status==='done' ? downloadMd(v.video_id) : processOne(v.video_id)" :title="v.status==='done' ? 'Download .md' : 'Process to .md'" x-text="v.status==='done' ? '.md' : 'Process'"></button>
<button class="btn btn-ghost !py-1 !px-2 text-xs" @click="openClip(v.video_id)" title="Extract transcript clip">Clip</button>
</td>
</tr> </tr>
</template> </template>
</tbody> </tbody>
@@ -251,7 +283,7 @@
<template x-if="!loading.detail && detail.video"> <template x-if="!loading.detail && detail.video">
<div class="space-y-5"> <div class="space-y-5">
<div class="glass p-5 flex flex-col md:flex-row gap-5"> <div class="glass p-5 flex flex-col md:flex-row gap-5">
<img class="thumb w-full md:w-80 h-44 md:h-48" :src="detail.video.thumbnail || ''" :alt="detail.video.title" onerror="this.style.visibility='hidden'" /> <img class="thumb w-full md:w-80 h-44 md:h-48" :src="'/api/thumbnails/'+detail.video.video_id" :alt="detail.video.title" onerror="this.style.visibility='hidden'" />
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h1 class="text-xl font-bold text-white" x-text="detail.video.title"></h1> <h1 class="text-xl font-bold text-white" x-text="detail.video.title"></h1>
<div class="text-sm text-zinc-500 mt-1" x-text="chanName(detail.video.channel_id)"></div> <div class="text-sm text-zinc-500 mt-1" x-text="chanName(detail.video.channel_id)"></div>
@@ -263,7 +295,12 @@
<div><span class="pill" :class="statusClass(detail.video.status)" x-text="detail.video.status"></span></div> <div><span class="pill" :class="statusClass(detail.video.status)" x-text="detail.video.status"></span></div>
</div> </div>
<div class="flex flex-wrap gap-2 mt-4"> <div class="flex flex-wrap gap-2 mt-4">
<template x-if="detail.video.url"><a class="btn accent-grad btn-primary" :href="detail.video.url" target="_blank" rel="noopener">Open on YouTube ↗</a></template> <button class="btn accent-grad btn-primary !py-1.5 !px-3 text-xs" @click="detail.video.status==='done' ? downloadMd(detail.video.video_id) : processOne(detail.video.video_id)" x-text="detail.video.status==='done' ? 'Download .md' : 'Process to .md'"></button>
<button class="btn btn-ghost !py-1.5 !px-3 text-xs" @click="downloadThumbnails([detail.video.video_id])">Thumbnail</button>
<button class="btn btn-ghost !py-1.5 !px-3 text-xs" @click="openClip(detail.video.video_id)">Clip</button>
<template x-if="detail.video.url"><a class="btn btn-ghost !py-1.5 !px-3 text-xs" :href="detail.video.url" target="_blank" rel="noopener">Open on YouTube ↗</a></template>
</div>
<div class="flex flex-wrap gap-1.5 mt-3" x-show="(detail.video.tags||[]).length">
<template x-for="t in (detail.video.tags||[]).slice(0,8)" :key="t"><span class="pill st-skipped" x-text="t"></span></template> <template x-for="t in (detail.video.tags||[]).slice(0,8)" :key="t"><span class="pill st-skipped" x-text="t"></span></template>
</div> </div>
</div> </div>
@@ -524,27 +561,119 @@
<!-- tool grid --> <!-- tool grid -->
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-4"> <div class="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="glass p-5">
<h3 class="text-sm font-semibold text-zinc-200">Re-render Markdown</h3> <!-- Re-render -->
<div class="tool-card glass p-5">
<div class="tool-ico accent-grad"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992V4.355M3.985 19.644v-4.99h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0011.667 0l3.181-3.183m-4.991-2.696v-4.99h-4.992"/></svg></div>
<h3 class="text-sm font-semibold text-zinc-100">Re-render Markdown</h3>
<p class="text-xs text-zinc-500 mt-1 mb-3">Rebuild every <span class="font-mono">.md</span> from stored segments using the current template.</p> <p class="text-xs text-zinc-500 mt-1 mb-3">Rebuild every <span class="font-mono">.md</span> from stored segments using the current template.</p>
<button class="btn btn-ghost w-full" :disabled="tools.reRendering" @click="reRender()"> <button class="btn btn-ghost w-full" :disabled="tools.reRendering" @click="reRender()">
<svg x-show="tools.reRendering" class="spin w-4 h-4" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" stroke-dasharray="40 20"/></svg> <svg x-show="tools.reRendering" class="spin w-4 h-4" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" stroke-dasharray="40 20"/></svg>
Re-render all Re-render all
</button> </button>
<p x-show="tools.msg" class="text-xs mt-2" :class="tools.msg.startsWith('Failed') ? 'text-rose-400' : 'text-emerald-400'" x-text="tools.msg"></p> <p x-show="tools.msg" class="text-xs mt-2" :class="tools.msg.startsWith('Failed') || tools.msg.startsWith('Fail') ? 'text-rose-400' : 'text-emerald-400'" x-text="tools.msg"></p>
</div> </div>
<div class="glass p-5"> <!-- Export -->
<h3 class="text-sm font-semibold text-zinc-200">Export catalog</h3> <div class="tool-card glass p-5">
<div class="tool-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2"/></svg></div>
<h3 class="text-sm font-semibold text-zinc-100">Export catalog</h3>
<p class="text-xs text-zinc-500 mt-1 mb-3">JSON / CSV / browsable HTML gallery of all videos.</p> <p class="text-xs text-zinc-500 mt-1 mb-3">JSON / CSV / browsable HTML gallery of all videos.</p>
<button class="btn btn-ghost w-full" @click="setView('export')">Export tools</button> <button class="btn btn-ghost w-full" @click="setView('export')">Export tools</button>
</div> </div>
<div class="glass p-5"> <!-- Search -->
<h3 class="text-sm font-semibold text-zinc-200">Cookie vault</h3> <div class="tool-card glass p-5">
<div class="tool-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3" stroke-linecap="round"/></svg></div>
<h3 class="text-sm font-semibold text-zinc-100">Search transcripts</h3>
<p class="text-xs text-zinc-500 mt-1 mb-3">Find moments across all scraped videos.</p>
<button class="btn btn-ghost w-full" @click="setView('search')">Search</button>
</div>
<!-- Analysis -->
<div class="tool-card glass p-5">
<div class="tool-ico"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M4 20h16v2H4zM6 16V8h3v8zm4 0V4h3v12zm4 0v-6h3v6z"/></svg></div>
<h3 class="text-sm font-semibold text-zinc-100">Analysis</h3>
<p class="text-xs text-zinc-500 mt-1 mb-3">Word cloud, top words, term timeline.</p>
<button class="btn btn-ghost w-full" @click="setView('analysis')">Open analysis</button>
</div>
<!-- Thumbnails -->
<div class="tool-card glass p-5">
<div class="tool-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l1.409 1.409M3.75 3.75h16.5a2.25 2.25 0 012.25 2.25v11.25A2.25 2.25 0 0120.25 19.5H3.75A2.25 2.25 0 011.5 17.25V6a2.25 2.25 0 012.25-2.25z"/></svg></div>
<h3 class="text-sm font-semibold text-zinc-100">Download thumbnails</h3>
<p class="text-xs text-zinc-500 mt-1 mb-2">Cache thumbnails locally for offline viewing.</p>
<select class="field !text-xs !py-1.5 mb-2" x-model="tools.thumbScope">
<option value="all">All done videos</option>
<option value="selection" :disabled="videos.selected.length===0" x-text="'Current selection (' + videos.selected.length + ')'"></option>
<template x-for="c in channels.items" :key="c.channel_id"><option :value="c.channel_id" x-text="c.name"></option></template>
</select>
<button class="btn btn-ghost w-full" :disabled="tools.busy==='thumbnails'" @click="downloadThumbnailsScope(tools.thumbScope)">
<svg x-show="tools.busy==='thumbnails'" class="spin w-4 h-4" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" stroke-dasharray="40 20"/></svg>
Download
</button>
</div>
<!-- Audio -->
<div class="tool-card glass p-5">
<div class="tool-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 18V6l10-2v12M9 18a3 3 0 11-6 0 3 3 0 016 0zm10-2a3 3 0 11-6 0 3 3 0 016 0z"/></svg></div>
<h3 class="text-sm font-semibold text-zinc-100">Download audio (MP3)</h3>
<p class="text-xs text-zinc-500 mt-1 mb-2">Pull audio tracks as MP3 files.</p>
<select class="field !text-xs !py-1.5 mb-2" x-model="tools.audioScope">
<option value="all">All done videos</option>
<option value="selection" :disabled="videos.selected.length===0" x-text="'Current selection (' + videos.selected.length + ')'"></option>
<template x-for="c in channels.items" :key="c.channel_id"><option :value="c.channel_id" x-text="c.name"></option></template>
</select>
<button class="btn btn-ghost w-full" :disabled="tools.busy==='audio'" @click="downloadAudioScope(tools.audioScope)">
<svg x-show="tools.busy==='audio'" class="spin w-4 h-4" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" stroke-dasharray="40 20"/></svg>
Download
</button>
</div>
<!-- Clip -->
<div class="tool-card glass p-5">
<div class="tool-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M7.848 8.25l1.536.887M7.848 8.25a3 3 0 11-5.196-3 3 3 0 015.196 3zm1.536.887a2.165 2.165 0 011.083 1.839c.005.351.054.695.14 1.024m9.211-3.294a3 3 0 11-5.196 3 3 3 0 015.196 3zm-.845 4.491c-.42.179-.84.302-1.262.367a2.165 2.165 0 00-1.083 1.839m0 0a2.16 2.16 0 01-.013.255m-1.78 1.054a3 3 0 11-5.196 3 3 3 0 015.196-3z"/></svg></div>
<h3 class="text-sm font-semibold text-zinc-100">Clip extractor</h3>
<p class="text-xs text-zinc-500 mt-1 mb-3">Pull transcript text between two timestamps.</p>
<button class="btn btn-ghost w-full" @click="openClip('')">Open clip tool</button>
</div>
<!-- Stats -->
<div class="tool-card glass p-5">
<div class="tool-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5"/></svg></div>
<h3 class="text-sm font-semibold text-zinc-100">Stats</h3>
<p class="text-xs text-zinc-500 mt-1 mb-3">Totals, per-channel breakdown, top tags.</p>
<button class="btn btn-ghost w-full" @click="loadStats('')">Open stats</button>
</div>
<!-- Cookies -->
<div class="tool-card glass p-5">
<div class="tool-ico"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 11.5a8.5 8.5 0 11-9-8.46 2.5 2.5 0 003 3 2.5 2.5 0 003 3 2.5 2.5 0 003 2.46zM9 14a1 1 0 100-2 1 1 0 000 2zm3 3a1 1 0 100-2 1 1 0 000 2zm4-4a1 1 0 100-2 1 1 0 000 2z"/></svg></div>
<h3 class="text-sm font-semibold text-zinc-100">Cookie vault</h3>
<p class="text-xs text-zinc-500 mt-1 mb-3">Drag-and-drop a <span class="font-mono">.txt</span> session for members-only scraping.</p> <p class="text-xs text-zinc-500 mt-1 mb-3">Drag-and-drop a <span class="font-mono">.txt</span> session for members-only scraping.</p>
<button class="btn btn-ghost w-full" @click="setView('cookies')">Manage cookies</button> <button class="btn btn-ghost w-full" @click="setView('cookies')">Manage cookies</button>
</div> </div>
<!-- Channels -->
<div class="tool-card glass p-5">
<div class="tool-ico"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h16v3H4zm0 6h16v3H4zm0 6h10v3H4z"/></svg></div>
<h3 class="text-sm font-semibold text-zinc-100">Manage channels</h3>
<p class="text-xs text-zinc-500 mt-1 mb-3">Add or remove tracked channels, process pending.</p>
<button class="btn btn-ghost w-full" @click="setView('channels')">Channels</button>
</div>
<!-- Watch mode (info) -->
<div class="tool-card glass p-5 border-dashed">
<div class="tool-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<h3 class="text-sm font-semibold text-zinc-100">Watch mode</h3>
<p class="text-xs text-zinc-500 mt-1 mb-3">Auto-poll a channel for new videos via CLI.</p>
<div class="code-block" x-show="tools.watchCmd">
<code x-text="tools.watchCmd"></code>
<button class="btn btn-ghost !py-1 !px-2 !text-[0.65rem]" @click="copyText(tools.watchCmd)">Copy</button>
</div>
<button class="btn btn-ghost w-full !text-xs" x-show="!tools.watchCmd" @click="tools.watchCmd='yt-scraper watch --interval 6h --channel <url>'">Show command</button>
</div>
</div> </div>
<!-- open folders --> <!-- open folders -->
@@ -622,6 +751,157 @@
</div> </div>
</main> </main>
<!-- ============ FLOATING JOB WIDGET ============ -->
<template x-if="scrape.jobId && view !== 'scrape'">
<div class="job-widget" x-transition.opacity>
<template x-if="!jobWidget.collapsed">
<div class="space-y-2.5">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<span class="w-2 h-2 rounded-full shrink-0" :class="scrape.error ? 'bg-rose-500' : (scrape.done ? 'bg-emerald-500' : 'bg-blue-400 animate-pulse')"></span>
<span class="text-xs font-semibold text-white uppercase tracking-wider" x-text="scrape.kind ? scrape.kind + ' job' : 'job'"></span>
<span class="pill !text-[0.6rem] !py-0" :class="scrape.error ? 'st-error' : (scrape.done ? 'st-done' : 'st-running')" x-text="scrape.error ? 'error' : (scrape.done ? 'done' : 'running')"></span>
</div>
<button class="jw-x" @click="jobWidget.collapsed = true" title="Collapse"></button>
</div>
<div class="flex justify-between text-[0.7rem] text-zinc-500">
<span>Progress</span>
<span class="font-mono" x-text="(scrape.progress.completed||0) + ' / ' + (scrape.progress.total||0) + (scrape.progress.total ? ' ('+scrapePct()+'%)' : '')"></span>
</div>
<div class="progress-track"><div class="progress-fill" :style="'width:' + scrapePct() + '%'"></div></div>
<div class="log-panel !p-2 !max-h-16 overflow-y-auto" x-show="scrape.log.length">
<div class="text-[0.65rem]" :class="l.startsWith('[') ? 'accent-text' : 'text-zinc-500'" x-text="scrape.log[scrape.log.length-1]"></div>
</div>
<div class="flex gap-1.5">
<button class="btn accent-grad btn-primary !py-1 !px-2 !text-xs flex-1" @click="setView('scrape')">Open console</button>
<template x-if="scrape.done">
<button class="btn btn-ghost !py-1 !px-2 !text-xs flex-1" @click="openFolder('markdown')">.md folder</button>
</template>
<template x-if="!scrape.done && !scrape.error">
<button class="btn btn-danger !py-1 !px-2 !text-xs flex-1" @click="cancelScrape()">Cancel</button>
</template>
</div>
</div>
</template>
<template x-if="jobWidget.collapsed">
<button class="jw-collapsed" @click="jobWidget.collapsed = false">
<span class="w-2 h-2 rounded-full" :class="scrape.error ? 'bg-rose-500' : (scrape.done ? 'bg-emerald-500' : 'bg-blue-400 animate-pulse')"></span>
<span class="text-xs font-semibold text-white" x-text="(scrape.kind||'job') + ' · ' + scrapePct() + '%'"></span>
</button>
</template>
</div>
</template>
<!-- ============ CLIP MINI-TOOL MODAL ============ -->
<template x-if="clip.open">
<div class="modal-backdrop" @click.self="closeClip()" x-transition.opacity>
<div class="modal-card w-full max-w-2xl" @click.stop>
<div class="modal-head">
<h2 class="text-base font-semibold text-white">Clip extractor</h2>
<button class="jw-x" @click="closeClip()"></button>
</div>
<div class="p-5 space-y-3">
<div>
<label class="block text-xs text-zinc-500 uppercase tracking-wider mb-1">Video ID</label>
<input class="field font-mono" type="text" placeholder="dQw4w9WgXcQ" x-model="clip.videoId" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-zinc-500 uppercase tracking-wider mb-1">From (MM:SS)</label>
<input class="field font-mono" type="text" placeholder="01:23" x-model="clip.from" @keydown.enter="runClip()" />
</div>
<div>
<label class="block text-xs text-zinc-500 uppercase tracking-wider mb-1">To (MM:SS)</label>
<input class="field font-mono" type="text" placeholder="02:45" x-model="clip.to" @keydown.enter="runClip()" />
</div>
</div>
<button class="btn accent-grad btn-primary w-full" :disabled="clip.loading" @click="runClip()">
<svg x-show="clip.loading" class="spin w-4 h-4" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" stroke-dasharray="40 20"/></svg>
Extract clip
</button>
<p x-show="clip.error" class="text-sm text-rose-400" x-text="clip.error"></p>
<template x-if="clip.result">
<div class="space-y-2 pt-2 border-t border-zinc-800/70">
<div class="flex items-center justify-between gap-2">
<div class="text-xs text-zinc-500">
<span class="text-zinc-200 font-medium" x-text="clip.result.title || clip.result.video_id"></span>
· <span class="font-mono accent-text" x-text="clip.result.from + ' → ' + clip.result.to"></span>
· <span x-text="(clip.result.segment_count||0) + ' segs'"></span>
</div>
<div class="flex gap-1.5">
<button class="btn btn-ghost !py-1 !px-2 !text-xs" @click="copyClip()">Copy</button>
<button class="btn btn-ghost !py-1 !px-2 !text-xs" @click="downloadClipTxt()">.txt</button>
</div>
</div>
<pre class="clip-result" x-text="clip.result.text"></pre>
</div>
</template>
</div>
</div>
</div>
</template>
<!-- ============ STATS MODAL ============ -->
<template x-if="stats.open">
<div class="modal-backdrop" @click.self="closeStats()" x-transition.opacity>
<div class="modal-card w-full max-w-3xl" @click.stop>
<div class="modal-head">
<h2 class="text-base font-semibold text-white">Stats</h2>
<button class="jw-x" @click="closeStats()"></button>
</div>
<div class="p-5 space-y-5">
<template x-if="stats.loading"><div class="text-zinc-500 text-sm text-center py-8">crunching…</div></template>
<template x-if="!stats.loading && stats.data">
<!-- totals -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="stat-box"><div class="stat-lbl">Videos</div><div class="stat-val" x-text="fmtNum(stats.data.totals?.videos)"></div></div>
<div class="stat-box"><div class="stat-lbl">Duration</div><div class="stat-val" x-text="humanDur(stats.data.totals?.duration_sec)"></div></div>
<div class="stat-box"><div class="stat-lbl">Views</div><div class="stat-val" x-text="fmtNum(stats.data.totals?.views)"></div></div>
<div class="stat-box"><div class="stat-lbl">Likes</div><div class="stat-val" x-text="fmtNum(stats.data.totals?.likes)"></div></div>
</div>
<!-- status breakdown -->
<div x-show="stats.data.status_breakdown && Object.keys(stats.data.status_breakdown).length">
<div class="text-xs text-zinc-500 uppercase tracking-wider mb-2">Status breakdown</div>
<div class="flex flex-wrap gap-2">
<template x-for="(n, k) in (stats.data.status_breakdown||{})" :key="k">
<span class="pill" :class="statusClass(k)"><span x-text="k"></span><span class="font-mono" x-text="n"></span></span>
</template>
</div>
</div>
<!-- channels -->
<div x-show="stats.data.channels && stats.data.channels.length">
<div class="text-xs text-zinc-500 uppercase tracking-wider mb-2">Per channel</div>
<div class="overflow-hidden rounded-lg border border-zinc-800/70">
<table class="tbl">
<thead><tr><th>Channel</th><th>Videos</th><th>Duration</th><th>Views</th></tr></thead>
<tbody>
<template x-for="c in (stats.data.channels||[])" :key="c.channel_id">
<tr><td class="text-zinc-200" x-text="c.name || c.channel_id"></td><td class="font-mono text-xs text-zinc-300" x-text="fmtNum(c.video_count)"></td><td class="font-mono text-xs text-zinc-300" x-text="humanDur(c.total_duration)"></td><td class="font-mono text-xs text-zinc-300" x-text="fmtNum(c.total_views)"></td></tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- top tags -->
<div x-show="stats.data.top_tags && stats.data.top_tags.length">
<div class="text-xs text-zinc-500 uppercase tracking-wider mb-2">Top tags</div>
<div class="flex flex-wrap gap-1.5">
<template x-for="t in (stats.data.top_tags||[]).slice(0,20)" :key="t.tag">
<span class="pill st-skipped"><span x-text="t.tag"></span><span class="font-mono" x-text="t.count"></span></span>
</template>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
</div> </div>
</body> </body>
</html> </html>
+81
View File
@@ -154,3 +154,84 @@ select.field { appearance: none; background-image: linear-gradient(45deg, transp
/* fade-up transition for views */ /* fade-up transition for views */
.fade-enter-active { transition: all 0.22s ease; } .fade-enter-active { transition: all 0.22s ease; }
.fade-enter-from { opacity: 0; transform: translateY(6px); } .fade-enter-from { opacity: 0; transform: translateY(6px); }
/* ===== bulk action bar (videos) ===== */
.bulk-bar {
background: linear-gradient(to right, rgba(244, 63, 94, 0.10), rgba(220, 38, 38, 0.04));
border-color: rgba(244, 63, 94, 0.35);
}
.row-selected { background: rgba(244, 63, 94, 0.08) !important; }
.row-selected td { border-bottom-color: rgba(244, 63, 94, 0.12); }
/* small accent for the row .md/Process buttons */
.btn.md-done { color: #4ade80; border-color: rgba(34, 197, 94, 0.3); }
.btn.md-done:hover { background: rgba(34, 197, 94, 0.12); color: #86efac; }
.btn.md-todo { color: #facc15; border-color: rgba(234, 179, 8, 0.3); }
.btn.md-todo:hover { background: rgba(234, 179, 8, 0.12); color: #fde047; }
/* ===== tool grid cards ===== */
.tool-card { transition: border-color 0.15s ease, transform 0.15s ease; display: flex; flex-direction: column; }
.tool-card:hover { border-color: rgba(244, 63, 94, 0.4); transform: translateY(-1px); }
.tool-ico {
width: 2.25rem; height: 2.25rem; border-radius: 0.6rem;
display: inline-flex; align-items: center; justify-content: center;
background: rgba(244, 63, 94, 0.12); color: #fb7185; margin-bottom: 0.6rem;
}
.tool-ico svg { width: 1.15rem; height: 1.15rem; }
.tool-card.border-dashed { border-style: dashed; }
/* ===== code block (watch command) ===== */
.code-block {
display: flex; align-items: center; gap: 0.5rem; justify-content: space-between;
background: #050507; border: 1px solid #27272a; border-radius: 0.5rem; padding: 0.45rem 0.6rem; margin-bottom: 0.5rem;
}
.code-block code { font-family: ui-monospace, "JetBrains Mono", "Cascadia Code", monospace; font-size: 0.72rem; color: #fb7185; overflow-x: auto; white-space: nowrap; }
/* ===== floating job widget ===== */
.job-widget {
position: fixed; bottom: 1.25rem; right: 1.25rem; z-index: 60; width: 20rem;
background: rgba(9, 9, 11, 0.92); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
border: 1px solid rgba(244, 63, 94, 0.35); border-radius: 1rem;
padding: 0.9rem; box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6);
}
.jw-x {
width: 1.5rem; height: 1.5rem; display: inline-flex; align-items: center; justify-content: center;
border-radius: 0.4rem; color: #71717a; font-size: 0.85rem; line-height: 1; cursor: pointer; transition: all 0.15s ease;
background: transparent; border: none;
}
.jw-x:hover { color: #fff; background: rgba(244, 63, 94, 0.18); }
.jw-collapsed {
display: flex; align-items: center; gap: 0.5rem; width: 100%;
background: transparent; border: none; cursor: pointer; padding: 0.3rem;
}
/* ===== modals ===== */
.modal-backdrop {
position: fixed; inset: 0; z-index: 80;
background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center; padding: 1.5rem;
}
.modal-card {
background: #0f0f12; border: 1px solid #27272a; border-radius: 1rem;
max-height: 88vh; overflow-y: auto; box-shadow: 0 24px 60px rgba(0, 0, 0, 0.7);
}
.modal-head {
display: flex; align-items: center; justify-content: space-between;
padding: 0.9rem 1.25rem; border-bottom: 1px solid #27272a;
position: sticky; top: 0; background: #0f0f12; z-index: 1;
}
/* ===== clip result ===== */
.clip-result {
background: #050507; border: 1px solid #27272a; border-radius: 0.6rem;
padding: 0.75rem; font-family: ui-monospace, "JetBrains Mono", "Cascadia Code", monospace;
font-size: 0.78rem; line-height: 1.5; color: #d4d4d8; white-space: pre-wrap;
max-height: 22rem; overflow-y: auto; word-break: break-word;
}
/* ===== stat boxes ===== */
.stat-box {
background: rgba(24, 24, 27, 0.6); border: 1px solid #27272a; border-radius: 0.75rem; padding: 0.75rem 0.9rem;
}
.stat-lbl { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.05em; color: #71717a; font-weight: 600; }
.stat-val { font-size: 1.25rem; font-weight: 700; color: #fff; font-family: ui-monospace, "JetBrains Mono", "Cascadia Code", monospace; margin-top: 0.15rem; }